Add Licensing and Update Notices to an EmDash Plugin
license-client.ts is a single file with no dependencies (MIT). It talks to a store running Vendwick, or to FluentCart, and gives your EmDash plugin:
- license activation, deactivation and checks,
- a cached status that survives store outages,
- an "update available" answer with the exact
pnpm updatecommand, - the two
.npmrclines buyers need to install from the store’s npm registry.
It runs on Cloudflare Workers and Node 20+ (fetch, AbortController and WebCrypto only).
Import it from the package, or copy the file into your plugin:
import { createLicenseClient, licenseNotice } from "@gatilab/emdash-vendwick/license-client";
A deployed EmDash site can’t replace its own code. "Updates" therefore means a notice and a command; the install itself is pnpm update plus a redeploy.
1. Declare the network access and a secret setting
// your plugin's definePlugin({...})
capabilities: ["network:request"],
allowedHosts: ["store.example.com"],
admin: {
settingsSchema: {
license_key: { type: "secret", label: "License key" },
},
},
secret fields are encrypted by EmDash and redacted from logs. Never put the key in a plain string setting, a URL or a log line.
2. Create the client
import type { PluginContext } from "emdash";
import { createLicenseClient, type LicenseCache } from "@gatilab/emdash-vendwick/license-client";
/** A cache backed by ctx.kv. Entries carry their own expiry because KV has none. */
function kvCache(ctx: PluginContext): LicenseCache {
return {
async get(key) {
const row = await ctx.kv.get<{ v: unknown; exp: number }>(`license:${key}`);
return row && row.exp > Date.now() ? (row.v as never) : null;
},
async set(key, value, ttlMs) {
await ctx.kv.set(`license:${key}`, { v: value, exp: Date.now() + ttlMs });
},
async delete(key) {
await ctx.kv.delete(`license:${key}`);
},
};
}
export function licenseClient(ctx: PluginContext) {
if (!ctx.http) throw new Error("network:request is not granted");
return createLicenseClient({
storeUrl: "https://store.example.com",
item: { id: 42, slug: "my-plugin" }, // id from the store; the slug also works on Vendwick
version: "1.4.0", // your plugin's installed version
siteUrl: ctx.site.url,
fetch: (url, init) => ctx.http!.fetch(url, init),
cache: kvCache(ctx),
// protocol: "fluentcart", // for stores still on FluentCart (needs item.id)
});
}
Options worth knowing:
| Option | Default | What it does |
|---|---|---|
ttlMs | 12 hours | How long status() and updateAvailable() trust a cached answer. |
graceMs | 7 days | How long past the TTL the last answer is served while the store is unreachable. |
timeoutMs | 10 s | Per request, through AbortController. |
retries | 2 | Extra attempts after a network error, HTTP 429 or 5xx. A business answer (expired, invalid) is never retried. |
3. Activate from an admin route
"license/activate": {
input: z.object({ key: z.string().trim().min(8).max(128) }),
handler: async (ctx) => {
const result = await licenseClient(ctx).activate(ctx.input.key);
if (result.status === "valid" || result.status === "site_inactive") await ctx.settings.set("license_key", ctx.input.key);
return { status: result.status, message: result.message, expires: result.expires };
},
},
Every call returns the same result model, whatever the protocol:
status | Meaning |
|---|---|
valid | Good, and active on this site. |
site_inactive | Good, but not activated on this site. |
expired | Past its expiry date. |
disabled | Switched off by the store. |
invalid | Unknown key or wrong product. |
limit_reached | Activation refused: every seat is in use. |
server_disabled | The store isn’t issuing licenses right now. |
deactivated | deactivate() worked. |
network_error | No usable answer. This never means the license is bad. |
Results also carry expires (lifetime or Y-m-d H:i:s), expiresAt (ISO), siteCount, licenseLimit (0 means unlimited), activationsLeft, stale and the raw body.
4. Check daily
Plugin cron runs once a day is plenty. status() asks the store only when the cache is older than ttlMs:
hooks: {
cron: async (event, ctx) => {
if (event.name !== "license-check") return;
const key = await ctx.settings.get<string>("license_key");
if (!key) return;
const client = licenseClient(ctx);
await client.status(key);
await client.updateAvailable(key);
},
},
// once, e.g. in plugin:activate: await ctx.cron?.schedule("license-check", { schedule: "17 3 * * *" });
If the store is down, status() keeps returning the last real answer with stale: true for the grace period. A valid license whose expiry date passes while offline turns expired.
5. Show an admin notice
Use peek() on hot paths: it reads the cache and never touches the network.
const key = await ctx.settings.get<string>("license_key");
const result = key ? await licenseClient(ctx).peek(key) : null;
const notice = result ? licenseNotice(result, "My Plugin") : { level: "info", message: "Enter your My Plugin license key to get update notices." };
// render notice.level / notice.message in your admin page
Keep enforcement soft. A missing or expired license should stop update notices, not break the buyer’s site.
const update = await licenseClient(ctx).updateAvailable(key);
if (update.available) {
// "Version 1.5.0 is available. Run: pnpm update @vendor/my-plugin@1.5.0, then redeploy."
console.log(update.latest, update.command);
}
updateAvailable() compares versions with SemVer precedence, prereleases included, and never offers a downgrade. Pass { beta: true } to ask the store for the beta channel.
6. Tell buyers how to install
Vendwick serves EmDash products from an npm-compatible registry at {store}/_vendwick/npm/, with the license key as the bearer token. npmrcLines() prints the two lines buyers add to the project’s .npmrc:
client.npmrcLines("@vendor"); // placeholder token
// [
// "@vendor:registry=https://store.example.com/_vendwick/npm/",
// "//store.example.com/_vendwick/npm/:_authToken=${VENDWICK_LICENSE_KEY}",
// ]
@vendor:registry=https://store.example.com/_vendwick/npm/
//store.example.com/_vendwick/npm/:_authToken=${VENDWICK_LICENSE_KEY}
Keep the token in an environment variable (VENDWICK_LICENSE_KEY) instead of committing it. Then pnpm add @vendor/my-plugin and pnpm update @vendor/my-plugin@latest work as usual.
pnpm 10 can refuse versions younger than minimumReleaseAge. If the buyer’s project sets it, they must exempt your scope or a fresh release won’t install:
# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- "@vendor/*"
Vendwick stores
The default protocol: "vendwick" posts to {store}/_emdash/api/plugins/vendwick/license/{check|activate|deactivate|version}. When that path answers 404 or something that isn’t JSON, the client retries once at the store’s EDD-style endpoint, {store}/_vendwick/license?edd_action=…. It never falls back to the site root, because a store that serves a static home page answers / before Vendwick sees the request.
FluentCart stores
protocol: "fluentcart" calls {store}/?fluent-cart=activate_license|check_license|deactivate_license|get_license_version with license_key, item_id, site_url and current_version, plus the activation_hash FluentCart issued on activation. FluentCart reports business failures as HTTP 422; the client reads those bodies and maps error_type to the statuses above.
Vendwick doesn’t answer FluentCart’s API. If you move a FluentCart store to Vendwick with the store importer (Vendwick 0.2.0), switch the client to protocol: "vendwick" and point it at the new store. Keep the same item.id: imported products still answer to their old FluentCart ids, and license keys are kept as they were. See docs/importing.md in the Vendwick package.
What leaves the site
The license key, the item id or slug, this site’s URL, the installed version and, when asked, beta=1. Nothing else.