# Build a Custom Plugin Server

Follow the guides for building and connecting a Lynvo-compatible Custom Plugin Server.

Create a Cloudflare Worker from the published starter, replace its example Plugin, and connect the deployed server to Lynvo. This guide covers the complete process from the core concepts through deployment.

## What is a Custom Plugin Server?

A Plugin Server is a deployed HTTPS service that translates Source pages into data Lynvo understands. Lynvo sends a URL to the Plugin Server, and the Plugin Server returns normalized Media Nodes for files, folders, groups, or content that must be resolved later.

The Plugin Server is the boundary between Lynvo and Source integrations. It owns the behavior shared by every Plugin:

- The public manifest at `GET /manifest`
- Bearer authentication for protected endpoints
- URL routing to the correct Plugin
- Request and response validation
- Finite usage limits
- Protocol error responses
- Deployment, logs, and operational status

Lynvo owns the interface around that service. It registers the Plugin Server,
selects it for matching URLs, saves extracted nodes, tracks opened markers and
selected links, and sends URLs to an external Android player. An
opened marker records only that an item was opened; neither Lynvo nor the Plugin
Server stores a playback position or resume state.

One Plugin Server can contain one Plugin or several Plugins. Deploy and connect the Plugin Server once, then publish each Plugin in the Plugin Server manifest.

> **Direct Media links**
>
> Lynvo handles Direct Media URLs without a Plugin Server. Use a Plugin Server when a Source page must be authenticated, parsed, traversed, or converted into playable links.

### What is a Plugin?

A Plugin is the Source-specific code inside a Plugin Server. It recognizes URLs from one website or service, fetches the Source data, and converts that data into Lynvo Media Nodes.

For example, a Plugin may support a self-hosted file index. The Plugin matches that index’s domain, handles its authentication rules, reads its folders and files, and returns playable or lazy Media Nodes. The Plugin Server handles the shared protocol around that work.

A Plugin should own:

- Its stable Plugin ID, display name, version, status, and icon
- Host and path matchers
- Source-specific credentials
- Upstream requests and response parsing
- Playable, group, folder, and lazy-node creation
- Source-specific errors and test fixtures

A Plugin should not implement bearer authentication, protocol routes, global usage enforcement, or Lynvo interface behavior. Keep those concerns in the Plugin Server shell so every Plugin follows the same contract.

Register every user-visible Plugin under `extensions.lynvo.plugins`. Lynvo reads this catalog to show the Plugin in [Settings](/settings/plugins) and route supported URLs to the Plugin Server.

### How Plugin Servers and Plugins fit together

The Plugin Server is the deployed service. Plugins are the Source integrations that run inside it:

1. Lynvo matches a URL against the Plugin Server Manifest
2. Lynvo sends the Extraction request to the Plugin Server
3. The Plugin Server authenticates and validates the request
4. The Plugin Server selects the matching Plugin
5. The Plugin converts the Source response into Media Nodes
6. The Plugin Server validates and returns the protocol response

## Create a Plugin Server with an agent

Generate the project first with `pnpm create lynvo-plugin-server@latest`. Then run the prompt below from the generated project directory. It tells the agent to extend the existing Worker instead of rebuilding the protocol shell.

```text
Open this existing Lynvo-compatible Plugin Server project.

Project rules:
- Keep the generated Hono Worker, Wrangler config, and package scripts.
- Keep @dg02002/lynvo-plugin-server-protocol as the protocol dependency.
- Keep createPluginServerRuntime and the four required route handlers.
- Read the bearer key from LYNVO_PLUGIN_SERVER_API_KEY.
- Do not add a frontend or move Source logic into route handlers.

Replace the example Plugin with support for this Source:
- Source name: your_source_name
- Supported hosts: your_source_host
- Supported paths: your_source_path_pattern
- Plugin ID: your_plugin_id

Implement the Source matcher, upstream requests, response parsing, and Media
Node mapping in src/plugins. Update the manifest and
extensions.lynvo.plugins in src/index.ts so they match the implementation.

Keep credentials out of URLs, manifests, responses, and logs. Use bounded
requests and return the protocol error codes for unsupported, temporary, and
permanent failures. Replace the usage fixture with durable accounting before
calling the Worker production-ready.

Add contract fixtures for supported URLs, unsupported URLs, authentication,
malformed requests, failure responses, and every Media Node kind you return.
Run pnpm check, pnpm test, and pnpm build. Report changed files and remaining
secrets or deployment configuration.
```

The agent should edit the Plugin implementation, manifest metadata, and tests. Keep the shared runtime and route wiring unchanged unless the protocol requires an explicit extension.

## Create a Plugin Server manually

Follow the manual path when you want to understand or control each part of the implementation.

### Prepare your development environment

Install Node.js and pnpm before you generate the project. You need a Cloudflare account for deployment and a Lynvo account for the final connection test.

Install or prepare:

- Node.js 26.7.0 or newer
- pnpm 11.20.0 or newer
- A Cloudflare account with Wrangler access
- A Lynvo account
- A Source website you are authorized to access and extract

When these requirements are ready, [generate your Plugin Server](/docs/plugin-server#generate-the-project).

### Generate the project

Run the creator from the directory where you want the project. It creates a
standalone Plugin Server project backed by a Cloudflare Worker and installs the
published protocol package.

```sh
pnpm create lynvo-plugin-server@latest my-lynvo-plugin-server
cd my-lynvo-plugin-server
cp .dev.vars.example .dev.vars
pnpm check
pnpm test
pnpm build
```

Set `LYNVO_PLUGIN_SERVER_API_KEY` in `.dev.vars`, then run `pnpm dev`. The generator installs dependencies unless you pass `--skip-install`. Run `pnpm install` before the checks when you skip installation.

The generator creates:

- `src/index.ts` with the manifest, shared runtime, and Hono routes
- `src/plugins/example.ts` with a deterministic example Source adapter
- `scripts/optimize-images.mjs` for Plugin icons in `public/icons/sources/`
- `tests/contract.test.ts` with the first protocol checks
- `wrangler.jsonc` and `.dev.vars.example`
- A semver dependency on `@dg02002/lynvo-plugin-server-protocol`

Edit these files first:

1. Replace the example matcher and Plugin metadata in `src/index.ts`
2. Replace the example Source adapter in `src/plugins/example.ts`
3. Add Source fixtures to `tests/contract.test.ts`

The `pnpm build` script performs a Wrangler dry run. Use `pnpm deploy` only after the contract checks pass and the production secret is configured.

The example usage response is a finite test fixture. Replace it with durable accounting before production deployment.

The generated project has no workspace or local-link dependency. You can move it to another repository without cloning Lynvo.

### Understand protocol version 1.0

Lynvo Custom Plugin Servers use Plugin Server Protocol version `1.0` over HTTPS and JSON. Version `1.0` defines four required endpoints and one optional discovery endpoint:

| Method | Route       | Authentication | Purpose                                            |
| ------ | ----------- | -------------- | -------------------------------------------------- |
| `GET`  | `/manifest` | Public         | Declares identity, matchers, features, and Sources |
| `POST` | `/discover` | Bearer         | Optionally identifies a Plugin for a Source URL    |
| `POST` | `/verify`   | Bearer         | Verifies the API key and Plugin Server readiness   |
| `GET`  | `/usage`    | Bearer         | Reports finite enforced usage metrics              |
| `POST` | `/extract`  | Bearer         | Resolves a Source URL or lazy node                 |

Implement `POST /discover` only when the manifest sets `features.discovery` to `true`. Use `Content-Type: application/json` for JSON responses. Return protocol error envelopes instead of unstructured error strings.

### Configure the manifest

The generated starter declares the manifest in `src/index.ts`. Keep the server ID stable after you connect the Worker to Lynvo.

Start with the generated object and replace the example values:

```ts
import {
  validPluginServerManifestFixture,
  type PluginServerManifest,
} from "@dg02002/lynvo-plugin-server-protocol"

export const manifest = {
  ...validPluginServerManifestFixture,
  pluginServerId: "com.example.my_plugin_server",
  displayName: "My Plugin Server",
  matchers: [{ hosts: ["media.example.com"] }],
  extensions: {
    lynvo: {
      plugins: [
        {
          id: "example-source",
          displayName: "Example Source",
          status: "active",
          version: "0.1.0",
          hosts: ["media.example.com"],
        },
      ],
    },
  },
} satisfies PluginServerManifest
```

Replace the `com.example` namespace with an identifier you control. Keep the `pluginServerId` unchanged after users register the Worker.

Manifest fields follow these rules:

| Field             | Required | Rule                                                 |
| ----------------- | -------- | ---------------------------------------------------- |
| `protocolVersion` | Yes      | Must equal `"1.0"`                                   |
| `pluginServerId`  | Yes      | Stable namespaced identifier                         |
| `displayName`     | Yes      | Human-readable Plugin Server name                    |
| `hasIcon`         | No       | `true` requires `iconUrl`; `false` forbids it        |
| `iconUrl`         | No       | HTTPS image URL; loopback HTTP is allowed locally    |
| `homepage`        | No       | HTTPS project URL                                    |
| `auth.type`       | Yes      | Must equal `"bearer"`                                |
| `usage.endpoint`  | No       | Defaults to `"/usage"`                               |
| `matchers`        | Yes      | Non-empty array                                      |
| `features`        | Yes      | Declares password, lazy node, and Basic Auth support |
| `extensions`      | No       | Namespaced non-core metadata                         |

Keep `matchers` and `extensions.lynvo.plugins` aligned with the Source code. Lynvo uses the manifest to select the Worker before it sends an Extraction request.

### Wire the shared routes

The generated `src/index.ts` wires each required endpoint through `createPluginServerRuntime`. Keep authentication, validation, usage, and response serialization in that runtime.

The generated route layer looks like this:

```ts
const runtime = createPluginServerRuntime<Env>({
  manifest,
  auth: { validate: ({ request, env }) => hasValidBearer(request, env) },
  usage: () => validUsageResponseFixture,
  extract: ({ targetUrl }) =>
    extractExampleSource(targetUrl, manifest.pluginServerId),
})

const app = new Hono<{ Bindings: Env }>()
app.get("/manifest", (context) =>
  runtime.handleManifest(context.req.raw, context.env)
)
app.post("/verify", (context) =>
  runtime.handleVerify(context.req.raw, context.env)
)
app.get("/usage", (context) =>
  runtime.handleUsage(context.req.raw, context.env)
)
app.post("/extract", (context) =>
  runtime.handleExtract(context.req.raw, context.env)
)
```

Replace the manifest, authentication function, usage provider, and Source implementation. Keep the four route handlers unchanged unless you need a protocol-level extension.

The starter returns a finite usage fixture so its contract tests can run without a database. Replace that provider with durable accounting before deployment.

## Build a Plugin and return Media Nodes

A Plugin recognizes one Source and converts its data into 4 product-level node types: direct media, container, folder, and lazy folder.

### Add a Source Plugin

The generated project includes one deterministic example Plugin. Replace it with your Source implementation before you deploy.

Start with these files:

1. Replace the Source implementation in `src/plugins/example.ts`
2. Update the matcher and Plugin metadata in `src/index.ts`
3. Add supported, unsupported, and failure fixtures to `tests/contract.test.ts`

The Source implementation returns a protocol response instead of a framework response:

```ts
import type { ExtractSuccessResponse } from "@dg02002/lynvo-plugin-server-protocol"

export const extractExampleSource = (
  targetUrl: string,
  pluginServerId: string
): ExtractSuccessResponse => ({
  plugin: {
    pluginServerId,
    displayName: "My Plugin Server",
    pluginId: "example-source",
    pluginName: "Example Source",
  },
  nodes: [{ kind: "playable", label: "Example item", url: targetUrl }],
  extensions: {},
})
```

Plugin metadata supports these fields:

| Field                   | Purpose                                        |
| ----------------------- | ---------------------------------------------- |
| `id`                    | Stable Plugin identifier                       |
| `displayName`           | Name shown by Lynvo                            |
| `description`           | Short Plugin capability summary                |
| `homepage`              | HTTPS Plugin project page                      |
| `hasIcon` and `iconUrl` | Plugin-specific WebP icon                      |
| `status`                | `active`, `maintenance`, `degraded`, or `down` |
| `version`               | Your adapter version                           |
| `routesToPluginId`      | Downstream Plugin in the same manifest         |
| `hosts` and `matchers`  | Source URLs handled by the Plugin              |
| `credential`            | Domain password or HTTP Basic Auth capability  |

A Plugin should own Source credentials, upstream requests, response parsing, Media Node creation, and Source-specific errors. The shared runtime should own bearer authentication, protocol validation, usage enforcement, and response serialization.

For multiple Plugins, keep one catalog as the source of truth for manifest metadata and request dispatch. Do not add Source-specific conditions to the Hono route layer.

## Choose among the 4 node types

Plugins return 4 product-level node types. Protocol version `1.0` represents them with 3 `kind` values:

| Product item           | Protocol kind | Required fields                         |
| ---------------------- | ------------- | --------------------------------------- |
| Direct media           | `playable`    | `label`, `url`                          |
| Display-only container | `group`       | `label`, `children`                     |
| Selectable folder      | `group`       | `label`, `selectable: true`, `children` |
| Lazy folder            | `resolvable`  | `label`, `nodeUrl`                      |

### Return direct media

Use `playable` when the URL points directly to a video or audio file. Lynvo can send this URL to the selected media player without another extraction request.

```ts {2,5}
const playableItem = {
  kind: "playable",
  id: "episode-1",
  label: "Episode 1",
  url: "https://cdn.example.com/episode-1.mp4",
  badge: "1080p",
  size: "1.4 GB",
  expiry: 1767225600000,
  status: "up",
}
```

`status` may be `up`, `down`, or `unknown`. `expiry` is an optional Unix timestamp in milliseconds.

### Return a display-only container

Use a non-selectable `group` to label and organize child nodes. A container cannot be selected as a playable item or folder.

```ts {2,6}
const seasonContainer = {
  kind: "group",
  id: "season-1",
  label: "Season 1",
  selectable: false,
  children: [playableItem],
}
```

### Return a selectable folder

Set `selectable` to `true` when the group represents a complete folder. The folder includes its children in the current response.

```ts {2,5}
const selectableFolder = {
  kind: "group",
  id: "all-seasons",
  label: "All seasons",
  selectable: true,
  children: [seasonContainer],
}
```

### Return a lazy folder

Use `resolvable` when loading the folder requires another request. Lynvo sends `nodeUrl` back to the same Plugin Server when someone opens the folder.

```ts {2,6}
const lazyFolder = {
  kind: "resolvable",
  id: "bonus-content",
  label: "Bonus content",
  badge: "Open folder",
  nodeUrl: "https://media.example.com/folders/bonus",
  resourceId: "bonus_v1",
}
```

The same Plugin Server must resolve every `nodeUrl` it emits. Never include credentials in `nodeUrl`, `resourceId`, labels, or metadata.

## Configure security and usage limits

Create one secret API key for Lynvo, then define the finite usage limits enforced by your server.

### Create the Plugin Server API key

The generated starter validates the `Authorization: Bearer` header through the shared protocol runtime. Create a random 32-byte key:

```sh
openssl rand -base64 32
```

Copy the generated value. Use the same value in Lynvo and in your Plugin Server environment.

Create the local secret file and set its value:

```dotenv
LYNVO_PLUGIN_SERVER_API_KEY=your_local_plugin_server_api_key_here
```

Keep `.dev.vars` out of version control. The template already ignores it.

Store the production key as a Cloudflare secret:

```sh
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEY
```

Lynvo sends this key in the `Authorization` header for `/verify`, `/usage`, and `/extract`. Never place it in a manifest, URL, response, browser bundle, or log.

If you replace the generated route layer, authenticate protected requests before reserving usage or sending upstream requests.

### Define and enforce usage limits

Every connected credential must have at least one finite usage metric. The Plugin Server must enforce the same limits it reports.

Define the response schema:

```ts {3-11}
export const usageResponseSchema = z.object({
  metrics: z
    .array(
      z.object({
        id: z.string().min(1),
        label: z.string().min(1),
        used: z.number().nonnegative().finite(),
        limit: z.number().positive().finite(),
        unit: z.string().min(1),
        period: z.enum(["daily", "monthly"]),
        resetsAt: z.string().datetime(),
        pluginId: z.string().min(1).optional(),
      })
    )
    .min(1),
})
```

Return one record for each independently enforced limit:

```json {3-11}
{
  "metrics": [
    {
      "id": "extract-requests",
      "label": "Extract requests",
      "used": 84,
      "limit": 1000,
      "unit": "requests",
      "period": "monthly",
      "resetsAt": "2026-08-01T00:00:00.000Z"
    }
  ]
}
```

Each metric must satisfy these invariants:

- `id` is unique within the response
- `limit` is positive and finite
- `used` is non-negative, finite, and no greater than `limit`
- `period` is `daily` or `monthly`
- `resetsAt` is an ISO 8601 timestamp
- `pluginId` identifies a Plugin-specific allowance

Use a Cloudflare Durable Object for atomic reservations. Reserve capacity before the adapter performs upstream work. Return `RATE_LIMITED` when no capacity remains. Settle the reservation after the adapter finishes, and release it when your accounting policy does not charge failed work.

> **Usage is authoritative**
>
> Do not report one allowance while enforcing another. Lynvo renders the metrics returned by your Plugin Server and does not own third-party accounting.

## Handle protocol requests and responses

Validate every extraction request and return either normalized Media Nodes or a structured error.

### Validate Extraction requests

`POST /extract` accepts a Source URL or a lazy-node follow-up request. Reject malformed bodies before the Plugin runs.

```ts {3-11}
import { z } from "zod"

const sourceInput = z.object({
  kind: z.literal("source"),
  sourceUrl: z.string(),
})

const nodeInput = z.object({
  kind: z.literal("node"),
  nodeUrl: z.string(),
  resourceId: z.string().optional(),
})
```

Add optional Plugin selection and credential fields to the request envelope:

```ts {2,4-8}
export const extractRequestSchema = z.object({
  input: z.discriminatedUnion("kind", [sourceInput, nodeInput]),
  pluginId: z.string().min(1).optional(),
  password: z.string().optional(),
  basicAuth: z
    .object({
      username: z.string(),
      password: z.string(),
    })
    .optional(),
})
```

A Source Extraction request uses `sourceUrl`:

```json
{
  "input": {
    "kind": "source",
    "sourceUrl": "https://media.example.com/watch/123"
  }
}
```

A lazy follow-up uses the `nodeUrl` returned by the same Plugin Server:

```json
{
  "pluginId": "example-media",
  "input": {
    "kind": "node",
    "nodeUrl": "https://media.example.com/folders/bonus",
    "resourceId": "bonus_v1"
  }
}
```

### Return successful responses

Every successful Extraction returns Plugin metadata, an array of Media Nodes, and an extensions object.

```json {2-7,9-15}
{
  "plugin": {
    "pluginServerId": "com.example.my-plugin-server",
    "displayName": "My Plugin Server",
    "pluginId": "example-media",
    "pluginName": "Example Media",
    "pageTitle": "Example collection"
  },
  "nodes": [
    {
      "kind": "playable",
      "id": "episode-1",
      "label": "Episode 1",
      "url": "https://cdn.example.com/episode-1.mp4"
    }
  ],
  "extensions": {}
}
```

The `plugin` object supports:

| Field            | Required | Purpose                    |
| ---------------- | -------- | -------------------------- |
| `pluginServerId` | Yes      | Matches the manifest       |
| `displayName`    | Yes      | Plugin Server display name |
| `iconUrl`        | No       | Plugin Server icon         |
| `pluginId`       | No       | Matches a declared Plugin  |
| `pluginName`     | No       | Plugin display name        |
| `pluginIconUrl`  | No       | Plugin icon                |
| `pageTitle`      | No       | Extracted page title       |
| `audio`          | No       | Audio-language summary     |

Lynvo owns layout, opened markers, selected links, and link caching.
An opened marker only records that an item was opened; Lynvo does not store
playback positions or resume state. Do not return interface instructions.

### Return structured errors

Return an error code that Lynvo can act on and a message safe for display. Do not expose stack traces, cookies, secret values, or internal network details.

```json {2-7}
{
  "ok": false,
  "error": {
    "code": "PASSWORD_REQUIRED",
    "message": "This Source requires a content password.",
    "retryAfterSeconds": 60
  },
  "extensions": {}
}
```

Protocol version `1.0` defines these error codes:

| Code                | Use when                                    |
| ------------------- | ------------------------------------------- |
| `UNSUPPORTED_URL`   | No Source matcher accepts the URL           |
| `AUTH_INVALID`      | The Plugin Server bearer key is rejected    |
| `AUTH_REQUIRED`     | Required Source credentials are missing     |
| `RATE_LIMITED`      | A finite usage limit is exhausted           |
| `TEMPORARY_FAILURE` | A later retry may succeed                   |
| `PERMANENT_FAILURE` | The Source cannot complete the request      |
| `PASSWORD_REQUIRED` | A content password is required              |
| `INVALID_PASSWORD`  | The supplied content password failed        |
| `NODE_EXPIRED`      | A lazy node target is no longer valid       |
| `PROTOCOL_MISMATCH` | A request or response violates the contract |
| `BAD_REQUEST`       | The JSON body or route input is malformed   |

## Test, deploy, and connect

Run the contract checks locally before deploying the Worker and adding it to Lynvo.

### Test the protocol contract

Run the generated checks before you deploy. The starter checks types, contract behavior, and the Wrangler bundle without requiring a live Cloudflare Worker.

Run the local checks:

```sh
pnpm check
pnpm test
pnpm build
```

Start the Worker in a separate terminal:

```sh
pnpm dev
```

Inspect the public manifest:

```sh
curl http://localhost:8787/manifest
```

Use `http://localhost:8787` when Lynvo and the Plugin Server run on the same
machine. Lynvo allows local HTTP only when the hostname is exactly
`localhost`; loopback IP addresses, LAN addresses, and other hosts must use
HTTPS.

To test with a remotely hosted Lynvo instance, expose the local Worker through
an HTTPS tunnel. Wrangler's interactive development server can start one with
the `t` shortcut. Verify the public origin before connecting it:

```sh
curl https://your-tunnel.example/manifest
```

The response must succeed and every absolute manifest URL, including Plugin
icon URLs, must use the public HTTPS origin. A tunnel commonly terminates TLS
before forwarding HTTP to the Worker. If the manifest generates absolute URLs
from the request, honor `X-Forwarded-Proto: https` or configure the public
origin explicitly. Never hardcode a temporary tunnel hostname.

Verify the local bearer key:

```sh
curl -X POST http://localhost:8787/verify \
  -H "Authorization: Bearer your_local_plugin_server_api_key_here"
```

Test a Source Extraction:

```sh
curl -X POST http://localhost:8787/extract \
  -H "Authorization: Bearer your_local_plugin_server_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "kind": "source",
      "sourceUrl": "https://media.example.com/watch/123"
    }
  }'
```

Extend the contract suite as you add Source behavior. Cover these boundaries:

- Valid and invalid manifest metadata
- Missing, invalid, and valid bearer credentials
- Finite usage responses and unique metric IDs
- Supported and unsupported Source URLs
- Playable, group, selectable folder, and lazy folder nodes
- Lazy-node follow-up through the same Plugin Server
- Password-required and invalid-password flows
- Malformed JSON and invalid request shapes
- Exhausted usage before upstream work starts
- Safe temporary and permanent failure responses

The starter includes the first four contract checks in `tests/contract.test.ts`. Add Source fixtures before you deploy a real Source implementation.

### Deploy the Plugin Server

Complete the local contract checks before you send the Worker to Cloudflare. The `pnpm build` command performs a dry run; `pnpm deploy` creates or updates the Worker.

Log in to Wrangler and configure the production bearer key:

```sh
pnpm wrangler login
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEY
```

Run the checks and deploy:

```sh
pnpm check
pnpm test
pnpm build
pnpm deploy
```

Call the four required endpoints at the deployed HTTPS origin. If the manifest enables discovery, call `POST /discover` too. Confirm the production secret works and the manifest contains only production URLs.

Use bounded upstream requests, reject unsupported hosts, and record structured logs without credentials. Keep the deployment origin stable after users connect it.

### Connect the Plugin Server to Lynvo

Add the Plugin Server after its production contract checks pass:

1. Open [**Settings**](/settings/plugins) in Lynvo
2. Find **Custom Plugin Servers**
3. Select **Add Custom Plugin Server**
4. Enter the deployed origin in **Server URL**, without `/manifest`
5. Enter the production bearer key in **API key**
6. Select **Add Custom Plugin Server**

Lynvo fetches `GET /manifest`, checks protocol version `1.0`, calls `POST /verify`, and stores the Plugin Server only when both responses pass validation.

Production and remote Plugin Server origins must use HTTPS. For local
development on the same machine, use an origin whose hostname is exactly
`localhost`, such as `http://localhost:8787`. Lynvo rejects plain HTTP origins
that use `127.0.0.1`, a LAN address, or any other hostname.

Test one URL for every declared Source. Open one lazy folder to confirm that Lynvo sends the follow-up request to the same Plugin Server.
