Skip to content
Gaurav TiwariDocs

Add Licensing and Update Notices to an EmDash Plugin

Version 0.2.0 Updated November 15, 2026

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 update command,
  • the two .npmrc lines 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:

OptionDefaultWhat it does
ttlMs12 hoursHow long status() and updateAvailable() trust a cached answer.
graceMs7 daysHow long past the TTL the last answer is served while the store is unreachable.
timeoutMs10 sPer request, through AbortController.
retries2Extra 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:

statusMeaning
validGood, and active on this site.
site_inactiveGood, but not activated on this site.
expiredPast its expiry date.
disabledSwitched off by the store.
invalidUnknown key or wrong product.
limit_reachedActivation refused: every seat is in use.
server_disabledThe store isn’t issuing licenses right now.
deactivateddeactivate() worked.
network_errorNo 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.

Share

Was this helpful?

Still stuck? Open a support ticket