Plugin Registry Distribution
This page defines the external official plugin registry contract used by Full Registry V1. The registry repository is separate from the app monorepo, but this spec is the durable app-side contract for repository layout, signing policy, static hosting, and official plugin metadata.
The active official external set includes lapis-canvas, lapis-graph,
lapis-markdown-lint, lapis-notebook, lapis-pdf, lapis-slides,
lapis-telemetry, and lapis-docs. Verified installs record
provenance: "official" in .obsidian/installed-plugins.json; the runtime uses
that app-owned provenance to classify the plugin as source official, list it
under Core plugins, and activate it through optional-core Safe Mode policy
while still loading files from /.obsidian/plugins/<id>/ and preserving the
existing external enablement file.
Repository Layout
The official registry repository uses reviewed source entries and generated static metadata:
lapis-plugin-registry/
README.md
package.json
pnpm-lock.yaml
entries/
official/
lapis-docs.jsonc
lapis-markdown-lint.jsonc
community/
example-community-plugin.jsonc
generated/
v1/
index.json
index.sig
plugins/
lapis-docs.json
trust/
root.json
root.sig
revoked.json
revoked.sig
schemas/
catalog-index.schema.json
plugin-detail.schema.json
plugin-release.schema.json
signed-envelope.schema.json
revoked.schema.json
scripts/
validate-registry.mjs
generate-registry.mjs
sign-registry.mjs
verify-registry.mjs
docs/
publishing-official-plugins.md
publishing-community-plugins.md
signing-and-key-rotation.md
entries/** are human-maintained review inputs. generated/v1/** is generated
by registry CI, committed for review, and published as static files. Pull
requests normally edit entries/**, schemas/**, or docs/**; CI regenerates
generated/v1/** and fails when generated output is stale.
Source Entry Contract
Each registry source entry is JSONC so maintainers can explain publication decisions in review. The generator strips comments and emits canonical JSON.
Official entries must include:
{
"$schema": "../../schemas/catalog-entry.schema.json",
"schemaVersion": 1,
"id": "lapis-docs",
"name": "Docs",
"description": "Rich document and spreadsheet editing for Lapis.",
"readmeUrl": "https://code.ju.ma/lapis-notes/lapis/raw/branch/main/packages/plugins/plugin-docs/README.md",
"author": "Lapis Notes",
"authorUrl": "https://app.lapis.md",
"channel": "official",
"status": "active",
"categories": ["documents", "editor"],
"platforms": ["web", "electron"],
"latestVersion": "2026.6.6",
"minAppVersion": "0.20.0",
"badges": ["official", "verified"],
"owner": {
"name": "Lapis Notes",
"verified": true,
},
"versions": {
"2026.6.6": {
"version": "2026.6.6",
"minAppVersion": "0.20.0",
"releasedAt": "2026-06-06T00:00:00.000Z",
"platforms": ["web", "electron"],
"bundle": {
"url": "https://code.ju.ma/lapis-notes/lapis/releases/download/official-plugin-assets-lapis-docs-2026.6.6/lapis-docs-2026.6.6.lapis-plugin",
"sha256": "<64 hex chars>",
"size": 50022551,
},
},
},
"contributes": {
"editorViews": [
{
"id": "lapis-doc",
"filenamePatterns": ["*.lapisdoc", "*.lapissheet"],
"extensions": ["lapisdoc", "lapissheet"],
},
],
},
}
The registry validator rejects unknown top-level fields unless the schema
explicitly reserves them, duplicate plugin IDs, non-HTTPS bundle URLs outside
local development, non-HTTPS readmeUrl values, official entries without a
verified official release signature during remote validation, and community
entries that use reserved Lapis IDs.
Generated V1 Metadata
The app fetches the locked official source at
https://registry.lapis.md/v1/index.json. The index is signed inline over
canonical JSON with the signatures field removed from the signed payload.
Plugin detail and revocation files use the same inline signature shape. Matching
.sig sidecars may be published for audit and generated-output stability, but
the app verifies inline signatures.
Required generated files:
generated/v1/index.json
generated/v1/index.sig
generated/v1/plugins/<plugin-id>.json
generated/v1/trust/root.json
generated/v1/trust/root.sig
generated/v1/revoked.json
generated/v1/revoked.sig
Each official Forgejo release publishes exactly one
<plugin-id>-<version>.lapis-plugin asset. A .lapis-plugin file is a
deterministic ZIP-compatible archive containing root release.signed.json plus
all installable plugin files. Archive entries may use ZIP method 0 (stored) or
method 8 (DEFLATE). release.signed.json must be the first local entry and
must be stored. The monorepo publisher writes plugin files in sorted path order
with DEFLATE level 6, a fixed ZIP timestamp of 1980-01-01T00:00:00, no
comments, and no extra metadata. Streaming ZIP data descriptors are allowed;
verifiers still reject unsafe paths, duplicate paths, directory entries,
encrypted entries, unsupported compression methods, missing signed files, and
unsigned extra files. release.signed.json is a signed envelope:
{
"signed": {
"schemaVersion": 1,
"type": "lapis.plugin.release",
"pluginId": "lapis-docs",
"version": "0.1.0",
"channel": "official",
"compatibility": {
"minAppVersion": "0.20.0",
"platforms": ["web", "electron"]
},
"runtime": {
"entries": {
"workspace": {
"path": "main.mjs",
"format": "esm",
"sharedDependencies": ["@lapis-notes/api", "svelte", "clsx"],
"requiresReloadOnUpdate": false
}
},
"compatibilityOverrides": {
"deprecatedHostModules": {
"workspace": []
}
}
},
"files": [
{
"path": "manifest.json",
"sha256": "<64 hex chars>",
"size": 1234
},
{
"path": "main.mjs",
"sha256": "<64 hex chars>",
"size": 6789
},
{
"path": "styles.css",
"sha256": "<64 hex chars>",
"size": 890
}
]
},
"signatures": [
{
"keyId": "lapis-plugin-release-2026-06",
"alg": "ed25519",
"sig": "<base64>"
}
]
}
Catalog metadata points at one bundle per release. Install and update download
that bundle, verify the catalog bundle SHA-256/size, extract
release.signed.json, verify the release signature, validate every signed file
path/hash/size against the embedded bytes, reject unsigned extra files, and then
stage the verified files into .obsidian/plugins/<id>/. Official external
Lapis plugins must publish ESM runtime entries only: release metadata must
mirror a packaged lapis.runtime.entries.workspace descriptor with
format: "esm" and must not include fallbackPath, CommonJS runtime entries,
or stale main.js compatibility artifacts. CommonJS remains supported only for
legacy Obsidian-compatible or community/hybrid plugin loading outside the
official external release path. Web installs use the same verification path in
a module worker when available so hashing, signature verification, and DEFLATE
extraction do not block the renderer; explicit main-thread verification remains
available for tests and fallback.
Install progress events preserve existing download byte fields and also include
generic processed byte/file progress for bundle extraction, file verification,
and staging writes. User-facing notifications must show determinate progress
when processedBytes/totalBytes or downloadedBytes/totalBytes are
available, including file index/count for staging so large plugins such as
lapis-docs do not appear frozen while verified files are extracted or written.
Direct registry installs and manually selected .lapis-plugin bundles enable
the installed plugin by default when a plugin manager is available. Callers that
are preserving an existing disabled update state must pass enable: false
explicitly.
The registry detail file repeats the fields needed to audit one plugin and points to immutable official release artifacts:
{
"schemaVersion": 1,
"id": "lapis-docs",
"name": "Docs",
"description": "Rich document and spreadsheet editing for Lapis.",
"readmeUrl": "https://code.ju.ma/lapis-notes/lapis/raw/branch/main/packages/plugins/plugin-docs/README.md",
"channel": "official",
"status": "active",
"owner": {
"name": "Lapis Notes",
"verified": true
},
"latestVersion": "2026.6.6",
"versions": {
"2026.6.6": {
"version": "2026.6.6",
"minAppVersion": "0.20.0",
"releasedAt": "2026-06-06T00:00:00.000Z",
"platforms": ["web", "electron"],
"bundle": {
"url": "https://code.ju.ma/lapis-notes/lapis/releases/download/official-plugin-assets-lapis-docs-2026.6.6/lapis-docs-2026.6.6.lapis-plugin",
"sha256": "<64 hex chars>",
"size": 50022551
}
}
},
"signatures": [
{
"keyId": "lapis-registry-2026-06",
"alg": "ed25519",
"sig": "<base64>"
}
]
}
Release assets are immutable by default and use deterministic per-plugin-version
release tags of the form official-plugin-assets-<plugin-id>-<version>. A
published version normally may not be changed in place; fixes publish a new
version. registry-assets:publish -- --force-overwrite is reserved for
exceptional repair work with explicit plugin selection and must be followed by
registry sync, generation, signing, and validation because bundle hashes and
signature metadata may change. README detail content is different: readmeUrl
is a signed mutable documentation pointer, so changing markdown content at the
same URL does not require a registry metadata republish.
official-plugin-assets-lapis-docs-2026.6.6/
lapis-docs-2026.6.6.lapis-plugin
Signing Roles
Registry signing keys and plugin-release signing keys are separate operational roles.
| Role | Signs | App trust usage |
|---|---|---|
registry | index.json, plugin detail files | Browse, detail, source provenance |
official-plugin-release | signed plugin release manifests | Install/update official plugin files |
revocation | revoked.json | Disable, warn, or block revoked installed builds |
root | trust-root and key rotation policy | Future key rotation; not a full TUF role in V1 |
Private keys are never committed. Registry CI may hold registry and revocation
signing secrets, while local release signing uses operator-provided key files.
The root private key stays offline except for key rotation. Public keys are
published in generated/v1/trust/root.json and embedded into app releases when
the key set changes.
generated/v1/trust/root.json records active and retired public keys with role,
algorithm, activation time, and optional expiry. Full TUF timestamp/snapshot
roles are out of scope for V1, but the shape must not prevent adding them later.
The V1 app trust set only embeds the current owned registry signing key,
lapis-registry-2026-06; the earlier bootstrap key is not trusted because its
private credentials are not part of the current publishing setup.
Static Hosting
The published generated/v1 directory and release assets must be available over
HTTPS with browser-compatible CORS:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
Access-Control-Allow-Headers: Content-Type
Cache policy:
index.json, plugin detail files,revoked.json, and trust-root files use a short cache lifetime or ETag validation so app refreshes can observe updates..lapis-pluginbundle assets are immutable and may use a long cache lifetime.- Bundle URLs must not be rewritten in place after publication.
- Static JSON files must be served with
application/json. - Release asset downloads must not require cookies, bearer tokens, or per-user authorization.
Registry Generation Commands
Registry repository CI must provide these commands:
pnpm registry:validate
pnpm registry:generate
pnpm registry:sign
pnpm registry:verify
Command responsibilities:
registry:validatereadsentries/**/*.jsonc, validates schemas, enforces reserved ID rules, and checks immutable HTTPS bundle URLs without requiring Forgejo assets to exist locally.registry:validate:remoteadditionally fetches every non-pending.lapis-pluginbundle, verifies the bundle SHA-256/size, extracts stored or DEFLATE-compressed entries, verifies the embedded official release signature, checks signed file hashes and sizes, rejects unsigned bundled files, and confirms packagedmanifest.jsonID/version compatibility. For structured Lapis runtime metadata, remote validation also rejects official releases whose signed runtime metadata does not matchmanifest.json, whose official workspace entry is not ESM-only, whose entry files are missing, whose module format does not match the entry file extension, or whose declared/scanned bare dependencies are absent from the generated public host-module catalogue for the selected platform. Deprecated and private host-module usage is preserved in diagnostics for manual/community compatibility paths. Official release validation treats private host modules as unsupported shared dependencies, and treats deprecated host modules as release errors unless the signed runtime metadata and packagedmanifest.jsonboth include an explicitruntime.compatibilityOverrides.deprecatedHostModules.<host>entry naming the deprecated specifier. That override downgrades only the deprecated-module diagnostic; it does not allow private host modules or unsupported platforms.registry:generatenormalizes entries, sorts by plugin ID and version, writes unsigned deterministicgenerated/v1/**documents, and keeps contribution summaries small enough for startup or settings-screen fetches.registry:signcanonicalizes generated JSON, signsindex.json, plugin detail files,revoked.json, and trust-root metadata, and writes both inlinesignatures[]arrays and sidecar.sigfiles when the host wants detached signatures.registry:verifyverifies the committed generated registry exactly as the app would consume it.
The Lapis monorepo creates official release inputs with:
pnpm plugin-release:keygen
pnpm plugin-release:package -- --plugin-id lapis-docs --version 0.1.0 --input dist --out dist-registry
pnpm plugin-release:sign -- --input dist-registry/lapis-docs-0.1.0/release.json --out dist-registry/lapis-docs-0.1.0/release.signed.json --key-id lapis-plugin-release-2026-06 --private-key-file private.pem
node scripts/plugin-release.mjs bundle --plugin-id lapis-docs --version 0.1.0 --release-dir dist-registry/lapis-docs-0.1.0 --signed-release-path dist-registry/lapis-docs-0.1.0/release.signed.json
pnpm plugin-release:keygen writes the default local plugin-release signing key
set to ~/.lapis/. The private PEM stays local or in Forgejo secrets, while the
raw base64 public key is the value embedded into the app and registry trust
metadata.
For remaining first-party official plugin assets, the Lapis monorepo owns the repeatable publication wrapper:
pnpm registry-assets:version
pnpm registry-assets:publish -- --dry-run
pnpm registry-assets:publish -- --verify
pnpm registry-assets:publish -- --publish
registry-assets:version updates official publishable plugin package and
manifest versions to CalVer before publication. Its default plugin set matches
registry-assets:publish rather than every bundled/core plugin. It defaults to
today’s YYYY.M.D value, accepts --plugins <ids> for subsets, and accepts
--patch/--patch-suffix for semver-compatible patch builds such as
2026.6.1-patch.1.
The publisher builds the selected plugin packages, stages normalized release
inputs using each plugin package’s package.json version, rejects package and
manifest version mismatches, signs and verifies each release manifest, packages
release.signed.json plus all plugin files into one deterministic
.lapis-plugin bundle with stored release.signed.json and DEFLATE-compressed
plugin files, and optionally publishes that single asset to the app repo Forgejo
release tag official-plugin-assets-<plugin-id>-<version>. It
mirrors the same per-plugin release to github.com/lapis-notes/releases when
LAPIS_RELEASES_GITHUB_TOKEN is configured.
--verify checks package versions and Forgejo release assets before a real
publish. Local TTY runs without --plugins prompt with checkboxes for plugins
that still need a release, defaulting to all unpublished plugins selected; use
--no-interactive to keep the previous publish-all-unpublished behavior.
Availability checks call Forgejo’s release-by-tag API for each deterministic
pluginId@version tag and only fetch release asset pages when the release
response does not include the expected .lapis-plugin asset. When no
--plugins selection is provided, already-published plugin/version assets are
skipped so the default path remains “publish all unpublished official plugin
versions”; explicit --plugins selections fail if that plugin/version already
exists unless --resume is set for a partial-upload recovery run.
--force-overwrite is valid only with explicit plugin selection and deletes
then re-uploads existing same-name assets in the deterministic release. Local
staging defaults to release-artifacts/official-plugin-assets/, with the
current run’s staged
Forgejo assets under forgejo-assets/ and ephemeral packaging work under
tmp/, which is deleted after each run. The app repo owns official plugin asset
builds and release signing; the registry repo owns entry review, generated
metadata, registry metadata signing, and final active metadata publication.
Selected official plugin packages are built in one Turbo invocation with
repeated --filter flags and --concurrency=1, while publish logs record the
Turbo remote-cache environment variables before that build so CI output can show
whether subsequent runs are expected to hit the shared cache.
The app monorepo also owns pnpm test:official-plugins. That gate builds the
current local official plugin packages, stages a signed local registry fixture
with test-only Ed25519 keys, routes Playwright registry and bundle requests to
that fixture, installs every plugin from officialPluginAssetDefinitions
through pluginDistribution.install(..., { requireOfficial: true }), and
asserts official provenance, default enabled state, installed file metadata,
clean ESM runtime diagnostics, reload persistence, install progress cadence,
and one representative runtime behavior per plugin. Shared CI verification and
the official plugin asset publish workflow run this gate before app handoff or
asset upload.
All official external Lapis plugins use the shared ESM-only Vite build contract.
Their builds clean stale dist output, emit a single main.mjs runtime chunk,
externalize only generated public host modules, bundle UI subpaths plus Svelte
internals, and fail when unsupported bare imports remain. Release staging traces
the declared runtime entry graph plus required assets and prunes stale chunks,
unused UMD/CommonJS builds, and duplicate format artifacts before signing.
Official installs fail before staging when signed runtime metadata is missing,
inconsistent, non-ESM, declares a fallback, or references unbundled private or
unsupported host modules.
Registry and release documentation should describe the same loader diagnostics
the app surfaces after install. Authors are expected to publish ESM-first
manifests with bundled normal dependencies, declare only generated public host
modules in sharedDependencies, reserve fallbackPath for non-official
community compatibility plugins, and document whether updates require a reload.
Plugin-defined import maps are not part of Full Registry V1; the app owns the
renderer import map and release validators check only manifest-declared runtime
entries plus scanned source dependencies. Verified bundled files for ESM entries
are served through versioned/hash-bearing app asset URLs after install, while
registry metadata keeps the bundle size and SHA-256 immutable for each published
version.
Any temporary official use of a deprecated public host module must be declared
under lapis.runtime.compatibilityOverrides.deprecatedHostModules in the
packaged plugin manifest and mirrored into the signed release manifest runtime
metadata. Overrides are host-specific, require the exact deprecated bare
specifier, and exist only to document reviewed migration windows for official
plugins.
Registry CI runs registry:validate, registry:generate, a generated-output
diff check, registry:sign when signing secrets are available, and
registry:verify on every pull request. Publishing is a static-site deployment
of generated/v1 after verification passes; official plugin bundles are
published separately from the app repo as deterministic Forgejo release assets
and mirrored to GitHub Releases on lapis-notes/releases for public
distribution.
Entry Policy
Official entries:
- must use a reserved Lapis plugin ID
- must be reviewed by Lapis maintainers
- must reference immutable-by-default
.lapis-pluginbundle assets - must verify with a current official plugin-release key
- must not rely on plugin-controlled manifest fields as proof of official status
- must include source package and source commit metadata in the signed release manifest for release audits
Community entries:
- must not use
lapis-*IDs or visually confusing names - must be labeled
communityeven when hosted in the same registry repo - may be deferred from app UI source management in V1
- must declare platform, minimum app version, and privileged capability needs
For V1 the official registry source is locked in the app. Community entries may be reviewed in the same external repository for future compatibility, but the app does not expose arbitrary user-added source management during this milestone.
Docs Pilot Registry Shape
The first generated registry must include the Docs pilot entry before Docs is removed from bundled workspace bootstrap.
generated/v1/index.json includes:
{
"schemaVersion": 1,
"generatedAt": "2026-05-31T00:00:00.000Z",
"registries": {
"lapis-official": {
"name": "Lapis Official Plugins",
"trustTier": "official"
}
},
"plugins": [
{
"id": "lapis-docs",
"name": "Docs",
"description": "Rich document and spreadsheet editing for Lapis.",
"author": "Lapis Notes",
"channel": "official",
"latestVersion": "2026.6.6",
"minAppVersion": "0.20.0",
"platforms": ["web", "electron"],
"categories": ["documents", "editor"],
"badges": ["official", "verified"],
"detail": "plugins/lapis-docs.json",
"contributes": {
"editorViews": [
{
"id": "lapis-doc",
"filenamePatterns": ["*.lapisdoc", "*.lapissheet"],
"extensions": ["lapisdoc", "lapissheet"]
}
]
}
}
],
"signatures": [
{
"keyId": "lapis-registry-2026-06",
"alg": "ed25519",
"sig": "<base64>"
}
]
}
generated/v1/plugins/lapis-docs.json includes the latest release, mutable
readmeUrl, and bundle URL/hash/size for the signed monorepo output. The detail
file must keep id, latestVersion, and release versions consistent with the
index.
The registry site build fetches each readmeUrl from generated registry
metadata and publishes deterministic documentation artifacts under
/v1/readmes/<plugin-id>/README.md and
/v1/readmes/<plugin-id>/README.html. The app plugin registry UI fetches the
registry-hosted markdown artifact derived from the plugin detail URL, resolves
relative README links and image URLs against that artifact, renders the markdown
through the app markdown preview component, and treats README failures as
documentation failures only: install, enable, update, and uninstall actions stay
independent of README loading. README content changes at the same source URL do
not require metadata changes or release republishing, but they do require a
registry site rebuild to refresh the published README artifacts.
The Docs pilot uses lapis-docs as the official installable ID. The standalone
manifest reports lapis-docs, and the workspace startup path includes an
explicit compatibility migration that copies legacy bundled docs plugin data
to lapis-docs when the target has no data yet. The registry may not treat the
plugin-controlled manifest ID as proof of official status.
When a user opens a file whose extension has no installed handler but may be covered by an official registry contribution, the workspace asks for confirmation before fetching the locked registry. If that metadata fetch fails, the app surfaces a notification and falls back to the normal missing-handler state instead of silently hiding the failure.
Revocation
generated/v1/revoked.json records revoked plugin versions:
{
"schemaVersion": 1,
"generatedAt": "2026-05-31T00:00:00.000Z",
"revoked": [
{
"pluginId": "lapis-docs",
"versions": ["0.1.0"],
"reason": "security",
"message": "Update to a fixed Docs release.",
"revokedAt": "2026-05-31T00:00:00.000Z"
}
],
"signatures": [
{
"keyId": "lapis-registry-2026-06",
"alg": "ed25519",
"sig": "<base64>"
}
]
}
Revocation handling is app-owned. Plugins cannot opt out of revocation policy
through their own manifests. The app verifies revoked.json with trusted
registry keys during catalog refresh, records matching official installed
versions as revoked in .obsidian/installed-plugins.json, elevates those rows
in the Plugins settings UI, and only permits recovery through disabling,
uninstalling, or installing a compatible verified replacement release.