Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

RoleSignsApp trust usage
registryindex.json, plugin detail filesBrowse, detail, source provenance
official-plugin-releasesigned plugin release manifestsInstall/update official plugin files
revocationrevoked.jsonDisable, warn, or block revoked installed builds
roottrust-root and key rotation policyFuture 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-plugin bundle 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:validate reads entries/**/*.jsonc, validates schemas, enforces reserved ID rules, and checks immutable HTTPS bundle URLs without requiring Forgejo assets to exist locally. registry:validate:remote additionally fetches every non-pending .lapis-plugin bundle, 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 packaged manifest.json ID/version compatibility. For structured Lapis runtime metadata, remote validation also rejects official releases whose signed runtime metadata does not match manifest.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 packaged manifest.json both include an explicit runtime.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:generate normalizes entries, sorts by plugin ID and version, writes unsigned deterministic generated/v1/** documents, and keeps contribution summaries small enough for startup or settings-screen fetches.
  • registry:sign canonicalizes generated JSON, signs index.json, plugin detail files, revoked.json, and trust-root metadata, and writes both inline signatures[] arrays and sidecar .sig files when the host wants detached signatures.
  • registry:verify verifies 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-plugin bundle 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 community even 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.