# Submit an app

URL: https://appflare.dev/catalog/submit/

> Add an app to the Appflare catalog with one manifest file and a pull request.

Adding an app takes one file, `apps/<slug>/appflare.jsonc`, in a pull request to
[appflare/catalog](https://github.com/appflare/catalog). You do not need to change the
app's own repository.

## Before you start

* **The app needs a public repository.** Its license does not decide whether it is
  listed: the catalog takes any app the platform can run, open-source,
  source-available, or with no license at all, and shows the license as the
  repository declares it. See [`license`](#1-write-the-manifest) below.
* **No proxies, tunnels, or circumvention tools.**
* **It deploys with wrangler.** If the app works with Cloudflare's Deploy button, it
  should work with Appflare. Bindings, compatibility settings, static assets, Durable
  Object migrations, queue consumers, and cron triggers all come from the app's own
  wrangler config; the manifest does not repeat them. An app that needs a build step
  before `wrangler deploy` (Vite, React Router, OpenNext) and has no `build.command`
  in its wrangler config names that step in `install.buildCommand`. The config's own
  `build.command` runs in the config's directory, as it does when you run
  `wrangler deploy` beside the config, so a command such as `npm run build` finds the
  `package.json` next to it. Each module reaches the Worker as the type `wrangler
  deploy` would upload it as, following the config's `rules` and wrangler's default
  ones: a `Text` rule for `**/*.svg` makes an imported SVG a string, not bytes.
* **It is self-contained.** Durable Objects and Workflows must be defined by the app's
  own Worker (or, in an app of several Workers, by another Worker of the same entry),
  not bound from a Worker outside the app. The only service binding an app may have
  is one to its own Worker (its `service` is the wrangler config's own `name`, as
  OpenNext's `WORKER_SELF_REFERENCE` is), optionally with an `entrypoint`. A service
  binding to any other Worker fails the pack, because an app must never be able to
  call another app or Appflare itself. An app made of several Workers lists them all
  in its entry; see [Apps of several Workers](#apps-of-several-workers).
* **Each Worker fits one upload.** The manager installs apps from inside a Workflow
  on the free plan, which allows 50 subrequests per invocation, and uploads each
  Worker in one request. It reads modules that lie next to each other in the
  artifact with one Range request per 8 MiB, so a build may emit as many modules
  as it likes, but a Worker's modules may add up to at most 32 MiB: the upload
  holds all of them in memory at once.
* **It fits the free plan, or says it does not.** An app that needs paid features
  is `"plan": "paid"`. Size does not decide the plan: Cloudflare accepts a Worker
  of up to 64 MiB uncompressed on every plan, and Appflare installs a Worker whose
  modules add up to at most 32 MiB on either.

The manager creates KV namespaces, D1 databases, R2 buckets, queues, Vectorize
indexes, and Pipelines streams with their sinks and pipelines for an app, attaches the app's Worker to the queues it consumes (dead-letter
queues included), and passes through Workers AI, Browser Rendering, Analytics Engine,
email sending (with its address restrictions), rate limits, Images, version metadata,
and plain variables. A rate limit declared the older way, in `unsafe.bindings` with
`"type": "ratelimit"`, is installed like one in `ratelimits`; every other `unsafe`
binding fails the pack, since Appflare cannot tell what it needs. Each install gets
rate limit counters of its own. A service binding to the app's own Worker is pointed
at the installed Worker, whatever name it is installed under; set
`install.fixedWorkerName` only when something else in the app needs one fixed name.
Other binding types cannot be installed yet: a wrangler config that declares one
(Workers VPC services, Secrets Store secrets, Tail Workers, dispatch namespaces, mTLS
certificates, Containers, AI Search, Media, Stream, inbound email `addresses`, Workers
Sites, and the rest wrangler knows) fails the pack with a message naming it, rather
than installing an app that would run without it. When the app works without that
section, drop it with the config patch the message names, such as
`{ "vpc_services": null }`. An mTLS certificate is uploaded to one account with its
private key, so an artifact cannot bring one. A key at the top of the config that the
packer's wrangler does not know (a binding added in a newer wrangler, or a key
wrangler never had) fails the pack the same way, since wrangler would drop it with no
more than a warning; drop it with the config patch the message names when the app
works without it.

Each Workflow is created for the Worker that defines it with the settings its
`workflows` entry gives it, `limits`, `concurrency`, `schedules` and
`default_retention`, as `wrangler deploy` sends them. Updates and rollbacks send the
settings of the version they deploy: Cloudflare puts a limit, concurrency or retention
that version leaves out back to its default for instances started once the change
takes effect, and a version without `schedules` is sent with an empty list of them. A
Workflow the deployed version no longer defines stays until the app is uninstalled,
but Appflare takes off the schedule an earlier version gave it, since each run would
fail. A binding that runs another Worker's Workflow cannot set any of these; wrangler
refuses that too. Cloudflare runs a Workflow on a schedule only on Workers Paid, so an
app with `schedules` needs `"plan": "paid"`. On Workers Free it also takes at most
1,024 steps and 100 running instances, and refuses the Workflow when the settings ask
for more.

An app that needs every name under one hostname, such as a tunnel that gives each
session `<id>.<hostname>`, sets `install.wildcardHostname` with a one-sentence
`reason` that the admin sees when assigning the hostname, such as
`"wildcardHostname": { "reason": "Each tunnel gets its own address under this hostname." }`. Appflare
then serves the app's Worker on the hostname the admin chooses and every name under
it, through Workers routes in one of the account's domains (see
[Wildcard domains](/guides/custom-domains/#wildcard-domains)). An app that needs its
hostname in a variable uses `{{wildcardHostname}}`, for example
`{ "name": "TUNNEL_DOMAIN", "label": "Tunnel domain", "default": "{{wildcardHostname}}" }`.

An app whose data lives in a PostgreSQL or MySQL database outside Cloudflare, reached
through Hyperdrive, is installed too: its manifest declares the database (see
[Databases elsewhere](#1-write-the-manifest)), and the admin enters its connection
string at install. The catalog's [install check](#2-open-a-pull-request) has no
database of the app's own to connect to, so it skips an app with Hyperdrive bindings
and says so, unless the catalog repository has a `HYPERDRIVE_TEST_URL` Actions secret
holding a connection string to a throwaway test database; then it installs the app
against that database. The check does not support mTLS certificate bindings yet: it
fails with a message naming the binding, so such an app cannot pass its pull request
checks. Queues, email sending, and a service
binding to the app's own Worker are deployed as the manager would deploy them; the
check points a self binding at the Worker it deploys. A service binding to any other
Worker already fails the pack.

## 1. Write the manifest

Create `apps/<slug>/appflare.jsonc`. The folder name must equal `slug`. A complete
example:

```jsonc
{
  "$schema": "https://appflare.github.io/catalog/schema/v1.json",
  "slug": "cut",
  "name": "Cut",
  "summary": "Self-hosted link shortener on Workers + KV.",
  "tagline": "Short links on your own domain",
  "repo": "MendyLanda/cut",
  "license": "MIT",
  "categories": ["utilities"],
  "authors": [{ "name": "Mendy Landa", "github": "MendyLanda" }],
  "maintainers": ["MendyLanda"],
  "source": { "ref": "v0.1.0", "sha": "6056400d47530aa87e4ae5764b37ffca9d00e87f" },
  "install": {
    "packageManager": "pnpm",
    "wranglerConfig": "wrangler.jsonc"
  },
  "plan": "free",
  "secrets": [
    {
      "name": "ADMIN_PASSWORD",
      "label": "Admin password",
      "help": "Used to sign in to /admin.",
      "generate": "password"
    }
  ],
  "vars": [
    {
      "name": "HOME_PAGE",
      "label": "Home page",
      "help": "What / shows. Short links and /admin work the same either way.",
      "type": "select",
      "options": [
        { "value": "default", "label": "Landing page" },
        { "value": "404", "label": "Empty 404" },
        { "value": "admin", "label": "Redirect to /admin" }
      ],
      "default": "default"
    }
  ],
  "postInstall": [
    {
      "type": "markdown",
      "content": "Open {{appUrl}}/admin and sign in with the admin password to create your first link."
    }
  ]
}
```

A field you leave out takes its default: `install.tier` is `"artifact"`,
`install.workerName` is the slug, `homepage` is the app's repository on GitHub, and
`requires`, `secrets`, `vars`, `postInstall` and `tokenPermissions` are empty lists. Leave them out
unless the app needs something else. The packer and the catalog checks refuse a field
the schema does not know, so a misspelled field is an error rather than a setting that
is quietly ignored; for a field that has moved, the message names its new place.

Points that need care:

* **`tagline`.** Required. What the app does, as one plain sentence of at most 80
  characters without a trailing period, such as `"Short links on your own domain"`.
  Managers show it under the app's name on catalog tiles. Write it for someone who
  is not a developer.
* **`categories`.** One to three categories the catalog lists the app under, each
  once, by id from this fixed list: `ai` (AI), `analytics` (Analytics), `bots`
  (Bots), `business` (Business), `chat` (Chat), `cms` (Websites and blogs),
  `community` (Community), `developer-tools` (Developer tools), `ecommerce`
  (E-commerce), `education` (Education), `email` (Email), `family` (Family), `files`
  (Files), `finance` (Finance), `games` (Games), `marketing` (Marketing), `media`
  (Media), `monitoring` (Monitoring), `networking` (Networking), `notes` (Notes),
  `notifications` (Notifications), `passwords` (Passwords), `privacy` (Privacy),
  `productivity` (Productivity), `remote-access` (Remote access), `scheduling`
  (Scheduling), `security` (Security), `sharing` (Sharing), `sync` (Sync), and
  `utilities` (Utilities). Managers show each category by its label.
* **`license`.** Write the license the app's own repository declares, as an SPDX
  license expression of ids from the current SPDX License List: `MIT`, `Apache-2.0`,
  `GPL-3.0-only`, `MIT OR Apache-2.0`, or a source-available license such as
  `BUSL-1.1`, `FSL-1.1-MIT`, `PolyForm-Noncommercial-1.0.0` or `Elastic-2.0`.
  Deprecated ids are refused: write `GPL-3.0-only` or `GPL-3.0-or-later` as the
  project's license notice says ("or any later version" means `-or-later`), never
  `GPL-3.0`. Use `NONE` when the repository publishes no license. For a license with
  no SPDX id, write `LicenseRef-<name>`, such as `LicenseRef-Acme`, and describe it
  in `licenseNote`. `NOASSERTION` and `SEE LICENSE IN <file>` are refused in a
  catalog entry: Appflare writes them only for an app
  [built from a repository](/guides/install-from-a-repository/) without one.
  Add `licenseNote`, one short line such as `"Source-available: production use
  restricted; see the license"`, when the id does not say what matters. Managers
  show the license on the app's card and page: `NONE` as **No license**, and
  source-available licenses (and any license with a note) marked
  **Source-available**. It is never a reason to leave an app out.
* **`source`.** Set `sha` to the full 40-character commit SHA, and `ref` to the tag it
  belongs to (`v1.2.3`) or the branch (`main`) for an untagged app. A semver tag
  gives the version `1.2.3`; anything else gives `0.0.0-<commit date>.<sha7>`.
* **`source.version`.** Only for repositories whose tags do not describe this app,
  such as a monorepo of templates. It overrides the version and must change whenever
  `sha` moves.
* **`install.health`.** How the health check reads the app, for example
  `"health": { "path": "/api/health" }`. Set `path` when `/` returns an error or
  needs a login, so the check requests a path that answers; it defaults to `/`.
  `mode` defaults to `"no-server-errors"`: any answer but a server error (5xx)
  passes. Set it to `"any-response"` when every route of the app, its health path
  included, asks for a sign-in, its own or one it checks from Cloudflare Access.
  Such an app can answer the check with an error of its own, and any answer from its
  Worker then counts as healthy. See [Health checks](/guides/health/#apps-behind-a-sign-in).
* **`openPath`.** Optional. When the app's interface is not at its root, such as an
  admin dashboard at `/dashboard`, set `"openPath": "/dashboard"` and the manager's
  **Open** buttons go there. It is a path only: it starts with `/`, has no query or
  fragment, and may end in `/`. Health checks, `{{appUrl}}` and the addresses the
  app's page lists stay the root, so a `postInstall` note that links to the
  dashboard writes `{{appUrl}}/dashboard`. A manager from before this field opens the
  root.
* **`install.buildCommand`.** One command, such as `pnpm --filter @scope/web build`,
  that the packer runs at the root of the repository after installing dependencies
  and before bundling, or a list of up to eight such commands run in order, such as
  `["pnpm run build:sphere", "pnpm run build"]`. Each runs without a shell and
  without credentials, with the repository's `node_modules/.bin` on its PATH, so
  pipes, redirects, quotes, variables, and `NAME=value` assignments are refused.
  pnpm and npm run no `pre` or `post` hooks there (`pnpm run build` skips
  `prebuild`), just as dependencies install with `--ignore-scripts`, so list such a
  step as a command of its own. The build stops at the first command that fails. A
  build whose commands all exit 0 without creating, changing or removing a single
  file in the repository built nothing (a tool given an option where it does not
  take one may print its usage and exit 0), and the pack fails with its output.
* **`install.buildEnv`.** Public constants the build compiles into the app, by name,
  such as `{ "VITE_API_ORIGIN": "https://api.example.com" }` for a Vite or SvelteKit
  app that reads settings at build time. The packer sets them for every build command
  and for wrangler's bundling, and the artifact records them. Everyone can read them,
  since the catalog publishes them and the build writes them into files anyone can
  download, so a name that looks like a credential (containing `SECRET`, `PASSWORD`,
  `PASSWD`, `PASSPHRASE`, `TOKEN`, `PRIVATE` or `CREDENTIAL`) is refused. So
  is a name that would change how the build runs rather than what it writes: those
  read by wrangler and what it runs (`WRANGLER_`, `CLOUDFLARE_`, `CF_`, `ESBUILD_`,
  `MINIFLARE_`, `WORKERD_`), Node.js and package managers (`NODE_`, `NPM_`, `PNPM_`,
  `YARN_`, `BUN_`, `COREPACK_`, `DENO_`), git and CI (`GIT_`, `GITHUB_`, `RUNNER_`,
  `ACTIONS_`, `CI`), shells (`BASH…`, `ENV`, `IFS`, `PS4`, `PROMPT_COMMAND` and the
  like), the dynamic linker (`LD_`, `DYLD_`, `GCONV…`), TLS and HTTP clients (`SSL_`,
  `OPENSSL_`, `CURL_`, `HTTP_PROXY` and the other proxy names), configuration
  directories (`XDG_`, `HOME`, `TMPDIR`), other toolchains (`PYTHON…`, `PERL5…`,
  `RUBY…`, `JAVA_`, `CARGO_`, `RUST…`, `GOPATH`, `GOFLAGS` and the other Go settings) and build tools (`TURBO_`, `NX_`,
  `PRISMA_`). These rules catch the known cases; catalog review reads every
  constant as well. Up to 32 upper-case names.
* **`install.installDirs`.** The directories whose dependencies the packer installs,
  in order, such as `[{ "path": "templates/blog" }]` for a template repository whose
  Worker has its own `package.json` and no root one. Omitted, only the root is
  installed; list `"."` as well when the root still needs its install. Each entry may
  name its `packageManager`; without one, a directory uses `install.packageManager`
  when it holds that manager's lockfile or none, and otherwise the one its lockfile
  names. Set `"lockfile": "none"` only when upstream ships no lockfile for that
  directory: the packer then resolves the dependencies itself, still with
  `--ignore-scripts`, and prints the sha256 of the lockfile it wrote. A directory
  that holds a lockfile always installs from it, and any other directory needs one in
  it or above it (a workspace's), or the pack fails. For a repository without a
  `package.json`, set `"installDirs": []` to install nothing: wrangler still bundles
  the entry and every file it imports by a relative path, an import of a package fails
  the pack, and any build command runs with no dependencies installed.
  `"lockfile": "none"` is no substitute there: pnpm and bun refuse a directory without
  a `package.json`, and npm looks for one in the directories above the checkout. Set
  `"devDependencies": false` on a directory whose devDependencies cannot be installed
  (a package from a private registry, say) and are not needed to bundle the Worker:
  it installs with pnpm `--prod`, npm `--omit=dev`, or classic yarn and bun
  `--production`, still from the lockfile. yarn 2 and later have no such install, so
  the pack fails there.
* **`install.configPatch`.** When the app's wrangler config needs a change before it
  installs from its pinned commit, open a pull request upstream first. Until it is
  merged, the entry may carry the change as a JSON merge patch (RFC 7386: an object
  merges key by key, `null` removes a key, an array replaces the whole list), with a
  comment linking the pull request:

  ```jsonc
  "install": {
    // Until https://github.com/<owner>/<repo>/pull/<number> is merged.
    "configPatch": { "build": null, "vars": { "DEBUG": null } }
  }
  ```

  After the build commands, the packer writes the patched config beside the original as
  `.appflare.wrangler.jsonc`, so relative paths resolve as before, bundles from it,
  and prints each change in the pack log. A TOML config is patched the same way and
  written as JSONC. A build command that reads the wrangler config itself still sees
  it unpatched. When the build generated the config that gets deployed (the
  Cloudflare Vite plugin's `dist/<name>/wrangler.json`, which `install.wranglerConfig`
  leads to through the build's redirect), the patch applies to that generated config
  instead, after the build, and the patched copy is written beside it: adding a rate
  limit or a binding works the same, but `main`, `assets.directory` and `build` of a
  generated config are the build's, so a patch that sets them fails the pack. Only these keys may be patched: `main` and `assets`, with paths
  relative to the config and without `..`; `build`, only
  to `null` when `install.buildCommand` builds instead; `services`, only to leave
  bindings out or to add one that points at a Worker of the same entry, optionally
  with `props` (see below);
  `kv_namespaces`, `r2_buckets` and `d1_databases`, only to add bindings or to leave
  out an `id`, `bucket_name` or `database_id` that is an empty string or a
  placeholder the upstream deploy script fills (`$NAME`, `${NAME}`, `{{NAME}}`,
  `<NAME>`), so the install provisions it; `vars`, to set a var to text or remove it
  with `null` (see below); `migrations`, only to rename
  `new_classes` to `new_sqlite_classes`, which the Free plan requires; `ratelimits`,
  only to add a rate limit an upstream deploy script adds, keeping the config's own;
  `ai`, only `{ "binding": "<NAME>" }`, to add the Workers AI binding an upstream
  deploy script adds to a config that has none. A section
  Appflare cannot install may be set to `null` to drop it, when the app works without
  it (`"vpc_services": null`, `"unsafe": null`), and so may a key the config sets that
  the packer's wrangler does not know (`"email": null`). Anything else fails with a message. An app of several Workers sets `configPatch` on each Worker
  in `install.workers`. The patch is part of the signed manifest.
  `appflare-pack inspect <checkout> --config <config> --manifest appflare.jsonc`
  applies it and shows what changed.

  A var a patch sets is part of that Worker's config, signed with the release: the
  admin does not see it in the app's settings. Use it for a value one Worker of an
  [app of several Workers](#apps-of-several-workers) needs for itself, such as the
  address it is reached at. Its text may hold the placeholders a var's `default`
  takes, filled in at every install, update and settings change, and again when the
  app's address changes:

  ```jsonc
  {
    "name": "github",
    "wranglerConfig": "packages/gatekeeper-github/wrangler.jsonc",
    "configPatch": { "vars": { "BASE_URL": "{{appUrl}}/gatekeeper/github" } }
  }
  ```

  A patched var may not have the name of a secret its Worker gets, nor of a catalog
  var that goes to that Worker. A value the admin chooses belongs in `vars` instead.
  An entry whose patch sets var text or adds an `ai` binding lists
  `"config-patch-values"` in `requires`. The admin sees these vars read-only in the
  app's settings with **Show technical names** on.

  A service binding in a patch may carry `props`, the JSON object the Worker it points
  at reads as `ctx.props` on every call through it, as `wrangler deploy` sends it. Its
  strings take the same placeholders:
  `{ "binding": "CONTEXT", "service": "context", "entrypoint": "Vendor", "props": { "sharingDomain": "{{appUrl}}" } }`.
  Props a wrangler config sets on a binding to its own Worker, or to another Worker of
  the entry, are kept the same way. An entry with props lists `"service-props"` in
  `requires`.
* **`install.wranglerConfigInline`.** When the repository commits no wrangler config
  at all (its deploy script writes one, or it relies on wrangler's automatic setup),
  open a pull request upstream that adds one. Until it is merged, the entry may carry
  the config itself, with a comment linking the pull request, and set `wranglerConfig`
  to where the packer writes it: `.appflare.wrangler.jsonc` at the root, or
  `<directory>/.appflare.wrangler.jsonc` for a Worker that lives in a directory of the
  repository. Relative paths in the config resolve from that directory.

  ```jsonc
  "install": {
    "wranglerConfig": ".appflare.wrangler.jsonc",
    // Until https://github.com/<owner>/<repo>/pull/<number> is merged.
    "wranglerConfigInline": {
      "main": "server/src/index.ts",
      "compatibility_date": "2026-01-20",
      "assets": { "directory": "./dist/client", "binding": "ASSETS" },
      "d1_databases": [{ "binding": "DB" }],
      "triggers": { "crons": ["*/20 * * * *"] }
    }
  }
  ```

  The packer writes it before installing dependencies, with the install's Worker name
  as `name`, and reads it like a config of the repository's. It may set only `main`,
  `compatibility_date` (required), `compatibility_flags`, `assets`, `vars`,
  `triggers`, `observability`, `placement`, `kv_namespaces`, `r2_buckets` and
  `d1_databases` without ids (the install provisions each), `queues`,
  `durable_objects` bindings to classes its own `migrations` create in
  `new_sqlite_classes`, `workflows` and `services` without a `script_name`, and the
  `ai`, `browser`, `images` and `version_metadata` bindings. A service binding to
  another Worker of the entry names it as the install does: the primary Worker by
  `install.workerName`, any other as `<workerName>-<name>`. The pack fails when the
  repository has a config of its own in that directory (patch that one instead), or
  when a build leaves a redirect to a config it generated there. The config cannot sit
  beside a `configPatch`; an app of several Workers sets it on each Worker in
  `install.workers`. The packer never runs wrangler's automatic configuration (what
  `wrangler deploy` does in a project without a config): it changes `package.json`
  and adds dependencies, so the install would no longer match the lockfile.
* **Package manager versions.** The packer reads which version the repository
  expects from its `package.json` (the nearest one at or above each install
  directory). yarn 2 or later must be pinned with `"packageManager": "yarn@4.x.y"`; it
  installs with `corepack yarn install --immutable --mode=skip-build` and
  `YARN_ENABLE_SCRIPTS=false`, since yarn 2 refuses classic yarn's flags, and
  corepack fetches the pinned yarn. A repository with a `.yarnrc.yml` but no such pin
  fails the pack: corepack would run classic yarn, which would run install scripts.
  npm installs with the npm that ships with Node.js 22 (npm 10), unless
  `"packageManager": "npm@11.x"` or `engines.npm` asks for a later major; npm 11 runs
  as `npx --yes npm@11.20.0 ci --ignore-scripts`, an exact release. A
  `package-lock.json` of `lockfileVersion` 3 that npm 10 refuses as out of sync
  ("Missing: … from lock file") or cannot resolve (`ERESOLVE`), as it does with some
  lockfiles npm 11 wrote, is installed again with npm 11, and the log says so. When
  no `package.json` names an npm but `.nvmrc` or `engines.node` asks for Node.js 24
  or later, which ships npm 11, the install uses npm 11 from the start. A
  `pnpm-lock.yaml` of `lockfileVersion` 6 (pnpm 8), which pnpm 10 refuses, installs
  with pnpm 9 as `npx --yes pnpm@9.15.9 install --frozen-lockfile`. The build itself
  still runs on Node.js 22.
* **What runs at install.** Install scripts are off, but that does not keep the
  repository's own code out of the install: `npx` can resolve a binary from the
  checkout's `node_modules`, and a `yarnPath` in `.yarnrc.yml` runs the yarn release
  file committed to the repository. The build commands run the repository's code
  anyway, which is why the pack runs without credentials.
* **`install.toolchains`.** Set `["rust"]` for a workers-rs app whose build runs
  `worker-build`: catalog CI then installs a pinned Rust toolchain with the
  `wasm32-unknown-unknown` target before the pack. Only the `artifact` tier takes
  it; an app that needs Rust cannot be built in an account's sandbox Worker, whose
  image has no Rust.
* **Worker size.** `pnpm pack-app` prints each Worker's modules, the Range requests
  the manager reads them with, and their size, for example
  `579 modules in 2 ranges, 11.44 MiB of at most 32.00 MiB`. Cloudflare accepts a
  Worker of up to 64 MiB uncompressed on every plan; the manager uploads at most
  32 MiB of modules in one request, and packing fails above that. The number of
  modules is not limited.
* **`install.wranglerConfig`.** Name the app's own wrangler config, the one you would
  run `wrangler deploy` next to. When the build leaves `.wrangler/deploy/config.json`
  beside it, as the Cloudflare Vite plugin does, the packer follows that redirect to
  the config the build generated, exactly as `wrangler deploy` does, and records both
  paths in the artifact. A generated config named directly also packs (the packer
  drops `legacy_env`, which older versions of the plugin write and wrangler accepts
  only through the redirect), but then relative paths in it, such as a D1 binding's
  `migrations_dir`, resolve from the build's output directory, so prefer the app's
  own config. When the repository keeps its config only
  as a template, such as `wrangler.toml.example` or `wrangler.jsonc.template`, name
  the template: the packer copies it to its real name (`wrangler.toml`) beside itself
  before the build runs and wrangler reads it. A real file of that name already in
  the repository must be identical to the template, or the pack fails.
* **Vectorize indexes and R2 buckets.** The wrangler config cannot say how to create
  a Vectorize index, so declare each binding's shape under `resources.vectorize`,
  with the metadata properties the app's queries filter on, if any:
  `"resources": { "vectorize": { "VECTORIZE": { "dimensions": 768, "metric": "cosine", "metadataIndexes": [{ "propertyName": "url", "type": "string" }] } } }`.
  The manager creates the metadata indexes (at most ten) right after the index,
  before the app writes a vector, since vectors written earlier are never indexed.
  An R2 bucket the app keeps files in only for a while can carry lifecycle rules
  under `resources.r2`, keyed by the R2 binding:
  `"resources": { "r2": { "FILES": { "lifecycle": [{ "id": "Delete temporary files", "prefix": "tmp/", "deleteAfterDays": 7 }] } } }`.
  A rule takes `deleteAfterDays`, `infrequentAccessAfterDays` or
  `abortMultipartUploadsAfterDays` (any of them, in whole days), and applies to every
  object when it has no `prefix`. The manager sets the rules when it creates the
  bucket and keeps Cloudflare's default rule (`Default Multipart Abort Rule`, which
  aborts unfinished multipart uploads after seven days), so that id is refused for a
  rule of the app's own. On the bucket, each rule's id starts with `appflare:`
  (`appflare:Delete temporary files`), which keeps it apart from rules added by hand;
  a declared id is at most 55 characters, so that the id on the bucket stays within
  64\. A later version's settings also reach an index or bucket an earlier version
  created. An update creates the metadata indexes the index lacks before the new
  version runs, and warns that vectors written before then are found by a filter on
  that property only once the app writes them again. It merges the declared rules
  into the bucket's by id, replacing only its own `appflare:` rules, and only once
  the new version serves: an update that fails before then leaves the bucket's rules
  as they were, so a rule that deletes objects never reaches a bucket the previous
  version still uses. An update never deletes a metadata index or removes a rule,
  not even one a later version no longer declares; its log names such a rule, which
  the admin can delete in the bucket's settings. A rollback does not change a
  bucket's rules either: the rules of the version it leaves stay and keep deleting
  or moving objects, and the rollback's log names each rule the version it returns
  to does not declare.
* **Databases elsewhere.** An app that keeps its data in PostgreSQL or MySQL outside
  Cloudflare binds it through Hyperdrive. Declare each Hyperdrive binding of the
  wrangler config under `resources.hyperdrive`, keyed by the binding, for example
  `"resources": { "hyperdrive": { "HYPERDRIVE": { "protocol": "postgres", "label": "Main database" } } }`,
  with an optional `help` sentence. The install form then asks for a connection
  string per binding (`postgres://user:password@host:5432/database`, or `mysql://`),
  and the manager creates a Hyperdrive configuration of the install's own from it,
  named `<worker name>-<binding>`. The packer refuses a Hyperdrive binding the
  manifest does not declare, and a declaration the config does not bind. The
  database is the admin's: an uninstall deletes the Hyperdrive configuration, never
  the database. Self-deploying entries cannot declare databases.

  Hyperdrive caches the results of read queries by default. For an app that must
  read its own writes at once, set `"caching": false` on the binding, and list
  `"hyperdrive-caching"` in `requires`:
  `"HYPERDRIVE": { "protocol": "postgres", "caching": false }`. The manager makes the
  configuration with caching off, and every update and settings change sets it back
  to what the entry says if it changed. `"caching": true` turns it on the same way.
  Without the field, the configuration keeps Cloudflare's default when it is made,
  and updates leave the setting alone.
* **Receiving email.** An app with an `email` handler that should receive mail at the
  admin's domain sets `install.emailRouting`: the addresses it wants in `rules` (a
  local part such as `inbox`, or a full address), and `"catchAll": true` for every
  other address of the domain, for example
  `"emailRouting": { "rules": ["inbox"], "catchAll": true }`. The install form asks
  for a domain of the account and Appflare sets up Email Routing there; see
  [Apps that receive email](/guides/email-apps/). An app of several Workers names
  the one that receives the mail with `worker` (see
  [Apps of several Workers](#apps-of-several-workers)). Not for the self-deploying
  tier.
* **Streams (Pipelines).** A stream's id in the wrangler config
  (`"pipelines": [{ "binding": "EVENTS", "stream": "<id>" }]`) belongs to the author's
  account, so describe the stream under `resources.pipelines`, keyed by the binding:
  `"resources": { "pipelines": { "EVENTS": { "schema": { "fields": [{ "name": "ts", "type": "timestamp", "required": true }] }, "sink": { "type": "r2_data_catalog", "bucket": "WAREHOUSE", "namespace": "app", "table": "events", "tokenSecret": "CATALOG_TOKEN", "rollIntervalSeconds": 60, "compaction": true } } } }`.
  The manager creates the stream (`<worker_name>_<binding>_stream`, schema as given,
  no HTTP endpoint), an R2 Data Catalog sink that writes to `namespace.table`, and a
  pass-through pipeline (`INSERT INTO <sink> SELECT * FROM <stream>`), and binds the
  stream. `bucket` names an R2 binding of the app, or any other name for a bucket of
  the install's own (`<worker name>-<name>`), which no Worker binds; a var such as
  `"{{workerName}}-warehouse"` tells the app its name. `tokenSecret` names a secret
  the form asks for (no `generate`, `optional`, `derive` or `seedOnly`): an R2 API
  token with Admin Read & Write, which Cloudflare keeps as the sink's credential and
  which the app may also use for R2 SQL. `compaction` and `snapshotExpiration`
  (`{ "maxAge": "30d", "minSnapshotsToKeep": 5 }`) turn on the catalog's table
  maintenance. Pipelines is on Workers Paid only, so the entry needs `"plan": "paid"`.
  Streams, sinks and pipelines cannot be changed once created: an update to a version
  that changes the schema or the table is refused, so that version needs a fresh
  install. A version that adds a stream gets it on update, which asks for the token in
  `tokenSecret` again. The packer refuses a Pipelines binding the
  manifest does not describe, and a description the config does not bind.
  Self-deploying entries cannot declare streams.
* **D1 SQL outside the migrations folder.** The packer reads each D1 binding's
  `migrations_dir` and `migrations_pattern` as `wrangler d1 migrations apply` does,
  relative to the config you name in `install.wranglerConfig`. When the app's D1 SQL
  is elsewhere, say where under `resources.d1`, keyed by the D1 binding, with paths
  relative to the repository's root:
  `migrationsDir` for another folder of migrations; `migrationsGlob` for a glob such as
  `prisma/migrations/*/migration.sql`, which works as wrangler's `migrations_dir`
  (the folder before the first `*`) plus `migrations_pattern`, so each file is
  recorded as wrangler records it (`20240101_init/migration.sql`) and they run in
  wrangler's order; `schema` for SQL files that run on every
  install and update after the migrations (the packer accepts them only when every
  CREATE TABLE, INDEX, TRIGGER and VIEW says `IF NOT EXISTS`, nothing is dropped or
  altered, and rows are only added when missing, with `INSERT OR IGNORE` or
  `ON CONFLICT DO NOTHING`; `UPDATE`, `DELETE` and other inserts are refused); and
  `postDeployMigrationsDir` for migrations that must wait until
  the new version serves all traffic. Migrations and post-deploy migrations are
  recorded in `d1_migrations` by file name, so their names must differ. A rollback
  reverts neither. For example
  `"resources": { "d1": { "DB": { "schema": ["src/db/schema.sql"] } } }`. A schema
  file creates what is missing and nothing more: `CREATE TABLE IF NOT EXISTS` never
  adds a column to a table that already exists, so when upstream changes a table in
  its schema file, installs made before the change keep the old table. Changing an
  existing table needs a migration.
* **A full schema file with migrations for older databases.** Some apps keep their
  whole current schema in one file (plain `CREATE TABLE`, default rows) and their
  migrations only bring databases made by older versions up to date, so the
  migrations fail on an empty database. Name that file as the binding's `baseline`:
  `"resources": { "d1": { "DB": { "baseline": "db/schema.sql" } } }`. On install,
  the baseline runs once on the new database, before the migrations, in one D1 query
  that also records every migration and post-deploy migration of that version in
  `d1_migrations` as applied, so none of them runs there. The baseline runs only on an
  empty database (no tables of the app's, nothing in `d1_migrations`), so updates never
  run it on a database the install already has; they apply only the migrations added
  after the installed version, as for any app. A database an update creates for a new
  binding gets its baseline, since it starts empty. The baseline need not be
  safe to run twice, but it may not `ATTACH`, `DETACH` or `DROP DATABASE`, set a
  PRAGMA (`PRAGMA defer_foreign_keys` is allowed), open or end a transaction, or name
  `d1_migrations` or a `sqlite_` or `_cf_` table (even in quotes), or end inside an
  unclosed comment or string, and it must create at least one table that is not
  temporary. A binding has a baseline or `schema` files, not both. This works only while
  upstream keeps the baseline in step with its migrations: an install gets the
  baseline as it is at the pinned commit and never the migrations of that commit, so
  a column a migration adds must also be in the baseline. Check that when you submit
  the entry and when you move its pin.
* **`secrets` and `vars`.** List every secret and setting the app reads from `env`
  that is not in its wrangler config. Use `"generate": "password"` for passwords and
  signing keys the user does not need to choose. Every var needs a value, typed in
  the form or from its `default`, before the install runs, unless it sets
  `"optional": true`. List a var from the wrangler config
  too when admins should be able to change it; without a `default`, the form starts
  with the wrangler config's value. The secrets the wrangler config lists in
  `secrets.required` belong here too; the pack names any it finds missing.
* **A secret the wrangler config sets as a var.** Some apps ship a placeholder
  password in `vars`. Declare the name as a secret: the packer then leaves the var
  out of the artifact and logs `var <NAME> is provided as a secret`, since a Worker
  cannot have a var and a secret of one name. Never declare one name both as a
  secret and as a var in `appflare.jsonc`; the pack refuses that.
* **Derived secrets.** When the app wants a hash of a password rather than the
  password, as Counterscale's `CF_PASSWORD_HASH` is a bcrypt hash, list the password
  as a secret and the hash as a second one with
  `"derive": { "from": "CF_PASSWORD", "method": "bcrypt" }`. The install form asks
  only for the password; the manager computes the hash (bcrypt, cost 10) and sets
  both, and again whenever the password gets a new value. The source must be an
  ordinary secret of the same manifest, neither optional nor derived, and a derived
  secret cannot be `generate` or `optional`. Self-deploying entries cannot derive
  secrets.
* **Web Push (VAPID) keys.** For an app that sends push notifications, give the
  private key secret `"generate": "vapid-private-key"`: the install form fills in a
  new P-256 private key, as the unpadded base64url of its 32 raw bytes (the format
  `web-push` and similar libraries take), and the manager refuses a value that is
  not one. Declare the public key as a var with
  `"derive": { "from": "VAPID_PRIVATE_KEY", "method": "vapid-public-key" }`: the
  manager computes it (the unpadded base64url of the 65-byte uncompressed point)
  at install and whenever the private key gets a new value, and the install and
  settings forms show it read-only. A derived var has no `default`, `options`,
  `"type": "select"` or `"optional": true`, and its source is a `vapid-private-key` secret of
  the same manifest that is not optional. The same `derive` works on a secret, for
  an app that reads the public key as one. Self-deploying entries cannot derive vars.
* **Raw 256-bit keys.** For an app that reads an encryption key as base64 of 32
  bytes (it runs `atob` and expects 32 bytes back), give the secret
  `"generate": "base64-key-32"`: the install form fills in 32 random bytes as padded
  base64 (44 characters), and the manager refuses a value that does not decode to
  32 bytes.
* **Multi-line secrets.** For a value of several lines, such as a PEM private key
  (a GitHub App's `GITHUB_APP_PRIVATE_KEY`), add `"multiline": true`. The install,
  update and settings forms ask for it in a monospace text area instead of a
  one-line password field, which would drop the line breaks. The Worker gets the
  value as pasted: Windows line endings become `\n` and spaces or tabs at the end
  of the last line are dropped; every other character, line breaks included, is
  kept. A text area cannot hide its text, so the value shows while the admin enters
  it and cannot be read back once saved. A multi-line secret cannot be `generate` or
  `derive`.
* **A first admin.** An app that has no sign-up page and expects its first admin
  account in the database can have Appflare add it at install. See
  [Seeding a first admin](#seeding-a-first-admin).
* **Optional secrets.** Add `"optional": true` to a secret the app works without,
  such as an SMTP password for a feature that stays off until it is set. The install
  form shows it among the optional settings and leaves it unset while its field is empty,
  updates never ask for it,
  and admins can set or remove it later in the app's settings. Self-deploying entries
  cannot have optional secrets: their installer runs with every secret the manifest
  lists.
* **Choices.** A var that takes one of a few fixed values can say so with
  `"type": "select"` and `"options"`, a list of `{ "value", "label" }` in the order
  the form shows them (2 to 20, with distinct values). The form shows cards for up to
  four options and a dropdown for more. `default`, when given, must be one of the
  values. For a var the app reads as JSON, each value is JSON text, such as `"true"`.
  `HOME_PAGE` in the example above is one.
* **Where to get a value.** `help` is shown as plain text, so a link written in it is
  not clickable. To point at the page where the admin gets the value, such as the
  page that creates an API key, give the secret or var a `link`:
  `"link": { "label": "Get a key", "url": "https://openrouter.ai/settings/keys" }`.
  The install, update and settings forms show it beside the field and open it in a
  new tab without sending the manager's address. `label` is one short line of at most
  40 characters; `url` is an https\:// URL of at most 500 characters, without a user
  name or password. A manager from before this field leaves the link out.
* **Placeholders.** The manager fills these in within `postInstall` text,
  `vars[].default`, and the values of the wrangler config's own `vars`, and fills
  vars in again on every update and settings change:

  * `{{appUrl}}` is the address the app is served at, as an `https://` URL without a
    trailing slash: its custom domain while workers.dev is
    [turned off](/guides/custom-domains/#turn-off-the-workersdev-url) for it, else
    its workers.dev URL. `{{appHostname}}` is that address without `https://`, such
    as `links.example.com`. When a domain takes over from workers.dev, or workers.dev
    from a domain, Appflare deploys the app's settings again so they follow. Use
    these for the address people open: a link in `postInstall`, a callback URL, or a
    public URL setting such as
    `{ "name": "PUBLIC_URL", "label": "Public URL", "default": "{{appUrl}}" }`.
  * `{{workerUrl}}` and `{{workerHostname}}` are always the workers.dev address
    (`https://<worker name>.<account subdomain>.workers.dev`), even while a custom
    domain serves the app. Use them only when the app must name that address. A
    setting that uses them keeps the workers.dev URL on when a domain goes live,
    since the setting would otherwise name an address that no longer answers.
  * `{{workerName}}` is the name the Worker is installed under.
  * `{{accountId}}` is the id of the account the app is installed in, for apps that
    query the Cloudflare API about their own account, such as the Analytics Engine
    SQL API.
  * `{{emailDomain}}` and `{{emailZoneId}}` are, for an app with
    `install.emailRouting`, the name of the domain the admin chose for its email
    (`example.com`) and that zone's id, for a sender such as
    `accounts@{{emailDomain}}` or a setting the app reads as JSON, such as
    `{"{{emailDomain}}":"{{emailZoneId}}"}` (placeholders in a JSON var's keys are
    filled in too; see JSON vars below). Both are empty while Appflare has no domain on record for the
    app's email. Moving the app's email to another domain deploys the settings again
    with the new one. An entry without `install.emailRouting` cannot use them, and
    one that does lists `"email-placeholders"` in `requires`.
  * `{{wildcardHostname}}` is the hostname of the app's
    [wildcard domain](/guides/custom-domains/#wildcard-domains) (no scheme, such as
    `tunnels.example.com`) for an entry with `install.wildcardHostname`, and is empty
    until the admin assigns one; assigning or removing it deploys the settings again,
    so the var follows the domain.
  * `{{accessTeamDomain}}`, `{{accessTeamName}}`, `{{accessAud}}` and
    `{{accessCertsUrl}}` are for an app that checks Cloudflare Access's sign-in
    itself (see `access` below): the account's Zero Trust team domain
    (`<team>.cloudflareaccess.com`, no scheme), the team name alone (`<team>`, for
    an app that builds `https://<team>.cloudflareaccess.com` itself), the audience
    (AUD) tag of the app's Access application, and
    `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`, where the keys that
    sign Access's JWTs are published. They work only in a var's value, never in
    `postInstall` or a secret. All four are empty while the app is not protected,
    so an app that checks them turns everyone away rather than trusting a request
    without a sign-in. Turning protection on or off deploys the settings again with
    the new values.
  * `{{stage}}` works only in the Worker names a self-deploying entry lists in
    `install.selfDeploying.workerNames`.

  Placeholder names are case-sensitive: `{{appURL}}` is refused, as is a placeholder
  in a field that does not take it. An
  [app of several Workers](#apps-of-several-workers) can also name each of its
  Workers.
* **JSON vars.** A wrangler config var whose value is not a string (an array, object,
  number, or boolean) reaches the Worker as that JSON value, as with `wrangler
  deploy`. When `vars` lists such a var, its `default` must be JSON text, such as
  `"[\"inbox@example.com\"]"`, and the install form asks for JSON. Placeholders are
  filled in throughout the value, object keys included. Older versions of Appflare
  fill in values only, so an entry that writes any placeholder in an object key
  (`{"{{appUrl}}":true}` as much as `{"{{emailDomain}}":"..."}`), in a JSON var or in
  the `props` of a service binding, lists `"email-placeholders"` in `requires`.
* **`requires`.** List account features the app needs beyond Workers, such as `r2`
  or `workers-ai`. `access` means the app goes with Cloudflare Access: the account
  needs a Zero Trust organization only while the app is protected, which with
  `"mode": "required"` is always; see `access` below.

  Some values name something Appflare must know how to do rather than something the
  account must have, and the catalog checks refuse an entry that needs one without
  it: `secret-keys` for a secret with a `key` other than its name,
  `service-props` for `props` on a service binding, `config-patch-values` for a
  config patch that sets var text or adds a Workers AI binding, `email-worker` for
  `install.emailRouting.worker`, `email-placeholders` for `{{emailDomain}}` or
  `{{emailZoneId}}` anywhere in the entry or its wrangler config, or any
  placeholder in an object key of a JSON var or of service binding `props`, and
  `hyperdrive-caching` for `caching` on a Hyperdrive binding. An Appflare that predates the
  feature does not know the value and leaves the entry out of its catalog, instead
  of installing the app wrong; a newer one asks the admin nothing about it. A
  [revision](#after-merge) may add `secret-keys`, together with keys on the secrets it
  adds (it may not change the key of a released secret), and `email-placeholders`,
  together with a setting or note that uses them.
* **`tokenPermissions`.** Only for apps that call the Cloudflare API with a token of
  their own, never Appflare's. Each entry names one permission group as the
  Cloudflare dashboard's token form shows it, where it applies, the access the app
  needs, and why:
  `{ "group": "DNS", "scope": "zone", "access": "edit", "reason": "Updates the DNS record for your home address." }`.
  `scope` is `"zone"` or `"account"`, `access` is `"read"` or `"edit"`, and `reason`
  is one plain sentence (start it with "Optional:" when the app works without the
  permission). `group` must be a group Appflare can select in the token link. Zone
  groups: DNS, Zone, Zone Settings, Analytics, Page Rules, Cache Purge, SSL and
  Certificates, Firewall Services, Load Balancers, Logs, Workers Routes, and Email
  Routing Rules. Cache Purge has one level, Purge, which `"access": "edit"` asks for.
  Account groups: Account Settings, Account Analytics, Billing, Logs, Magic Transit,
  Workers Scripts, Workers KV Storage, Workers R2 Storage, Workers R2 Data Catalog,
  Workers R2 SQL, Workers Tail, Workers Containers, D1, Queues, Vectorize, Images,
  Hyperdrive, Pipelines, Secrets Store, Email Sending, Email Routing Addresses,
  "Access: Apps and Policies", and "Access: Organizations, Identity Providers, and
  Groups". Images is for apps that call the Images REST API; a Worker's Images
  binding needs no token. The secret that takes the token sets
  `"cloudflareToken": true`, and the install form shows how to create the token
  next to it. At most one secret does, and it cannot be generated, derived or
  seed-only.
* **`access`.** Appflare can put
  any installed app behind Cloudflare Access, so only the manager's users reach it
  (Appflare's own health checks get in with a service token of the app's own). The
  install form offers it with the switch off. The optional `access` block changes
  that:

  * `"mode": "required"` for an app with no sign-in of its own, or one that relies
    on Access's: it installs only protected, and its protection cannot be turned off.
    Such an app installs only on accounts that have a Zero Trust organization and a
    Cloudflare token with the Access permissions (Access: Apps and Policies,
    Access: Service Tokens, and Access: Organizations, Identity Providers, and
    Groups); on any other account the install is refused and says which is missing.
  * `"mode": "recommended"` starts the install form's switch on.
  * `"bypass"` lists paths that stay public while the app is protected, such as
    share links or a webhook: `"bypass": ["/s/*", "/.well-known/*", "/api/webhook"]`.
    Each starts with `/` and may end in `/*` for everything under it (`/s/*` does not
    cover `/share`); no other wildcard, query or fragment, at most 10. They are public
    on every address the app answers on: its workers.dev address while it is on, its
    custom and external domains, and a wildcard domain's base and every name under it. They are
    never public on version previews. Do not list the health check path: Appflare's
    checks sign in with the app's token.

  An entry with `"mode": "required"`, or with a var whose default uses one of the
  Access placeholders, must also list `"access"` in `requires`. That is what keeps
  it away from a manager too old to protect apps or fill those placeholders in:
  such a manager does not know the `"access"` requirement and leaves the entry out
  of its catalog, instead of installing the app unprotected or with the
  placeholder as written. Without `"mode": "required"` the requirement holds only
  while the app is protected: the app still installs, unprotected, on an account
  without Zero Trust, and its catalog page says Access is needed only if you
  protect it.

  A released app can take up the `access` block, change it or drop it, and add
  `"access"` to `requires`, with a [revision](#after-merge) instead of a new pin.

  Not for the self-deploying tier. Appflare's per-app service tokens are account
  service tokens, so an Access rule an admin adds in the Zero Trust dashboard with
  the **Any Access Service Token** selector lets them in too. An app that verifies
  the `Cf-Access-Jwt-Assertion` header itself reads the Access placeholders above
  from its vars.
* **`authors`.** Who wrote the app upstream, shown on the catalog card and the app
  page: one or more `{ "name", "url"?, "github"?, "x"? }`, with handles written
  without `@`. Optional; without it the catalog lists the owner of `repo`. Changing
  only `authors` needs no new release: the catalog index reads it from the manifest.
* **`maintainers`.** At least one GitHub username: the people who package the app
  for the catalog, shown as **Packaged by** on the app page. See
  [CODEOWNERS](#codeowners).
* **`bump`.** Optional. Version bumps of an artifact tier app merge themselves once
  their checks pass, the install check included. `"bump": { "autoMerge": false }`
  has a maintainer merge each one instead, for an upstream whose releases often
  break installs or need a migration guide. See
  [Version bumps](/catalog/bumps/#who-merges-a-bump).
* **`revision`.** Optional; leave it out for a new app. Raise it to publish a change
  to the form or copy of a version that is already released. See
  [After merge](#after-merge).

Every field is described in the [manifest reference](/catalog/manifest-reference/).
Add an optional `apps/<slug>/README.md` for notes.

## Static sites without Worker code

A wrangler config with `assets` and no `main` is a Worker that only serves its static
assets, and Appflare installs it the way `wrangler deploy` uploads one: the artifact
carries the assets and no Worker modules, and the manager uploads the Worker with its
assets and compatibility settings alone. A build that writes the asset directory runs
as usual, from the config's `build.command` or `install.buildCommand`; a repository
without a `package.json` sets `"installDirs": []`.

Such a Worker has no code to use anything else, so the pack fails when it has
bindings (`vars` included), catalog `secrets` or `vars`, Durable Objects, cron
triggers, queue consumers, an `assets.binding`, or `assets.run_worker_first`.
Observability, placement, limits and `cache` settings are left out, as wrangler
leaves them out, and the pack log says so. The health check requests
`install.health.path` (default `/`) as for any app, so the site should answer there. When the asset
directory is the repository root, it needs an `.assetsignore` that leaves out
everything else that is not part of the site (the wrangler config, the sources), as
`wrangler deploy` would upload it too; the packer always leaves out `.git`,
`.wrangler` and `node_modules` directories, whatever `.assetsignore` says.

`_redirects` and `_headers` at the root of the asset directory work as they do with
`wrangler deploy`, for a static site and for a Worker with code alike: the packer
records their rules with the artifact instead of serving them as files, and the manager
sends them with every upload. A sandbox tier entry that is a static site, sets
`"installDirs": []`, or has a `multiline` secret needs an up-to-date sandbox Worker,
as does one with `buildEnv`, `"devDependencies": false`, Vectorize `metadataIndexes`
or `resources.r2`;
an older one is refused with a message to update the sandbox first.

## Seeding a first admin

Some apps store their users in D1 and only let an existing admin create others,
so upstream tells you to insert the first admin by hand, or ships a seed file with
a default password. A catalog entry can instead have Appflare insert that row once,
at install, from values the admin enters in the install form. Declare it under
`resources.d1[binding].seed`:

```jsonc
"secrets": [
  { "name": "ADMIN_PASSWORD", "label": "Admin password", "generate": "password", "seedOnly": true }
],
"vars": [
  { "name": "ADMIN_USERNAME", "label": "Admin user name", "seedOnly": true }
],
"resources": {
  "d1": {
    "DB": {
      "schema": ["worker/schema.sql"],
      "seed": {
        "hashes": {
          "admin": {
            "from": "ADMIN_PASSWORD",
            "method": "pbkdf2-sha256",
            "iterations": 100000,
            "saltBytes": 16,
            "keyBytes": 32,
            "encoding": "base64url"
          }
        },
        "statements": [
          {
            "sql": "INSERT OR IGNORE INTO users (username, password_hash, password_salt, is_admin) VALUES (?, ?, ?, 1)",
            "params": [{ "var": "ADMIN_USERNAME" }, { "hash": "admin" }, { "salt": "admin" }]
          }
        ]
      }
    }
  }
}
```

* **Statements.** Each is exactly one `INSERT OR IGNORE`, or one `INSERT ... ON
  CONFLICT ... DO NOTHING`, so a retried step keeps the row the first attempt added.
  `WITH`, `DO UPDATE`, other statement kinds (`CREATE`, `UPDATE`, `DELETE`, `PRAGMA`,
  `ATTACH` and the like), and the `d1_migrations`, `sqlite_` and `_cf_` tables are
  refused. At most 10 statements per binding.
* **Params.** Values are never written into the SQL. Put an anonymous `?` wherever a
  value goes and list one param per `?`, in order (at most 20): `{ "var": NAME }` or
  `{ "secret": NAME }` for a var or secret of the manifest, `{ "hash": ID }` or
  `{ "salt": ID }` for a hash of `hashes`, or `{ "value": "text" }` for literal text.
  D1 binds them. Numbered (`?1`) and named (`:name`) parameters are refused, as is a
  count of `?` that differs from the params.
* **Hashes.** Hash a password the way the app checks it. `pbkdf2-sha256` takes
  explicit `iterations` (at most 100,000, the most Cloudflare Workers derive),
  `saltBytes` and `keyBytes` (16 to 64) and an `encoding` for both the hash and the
  fresh random salt: `base64url` (unpadded), `base64` or `hex`. `bcrypt` gives a
  `$2b$` hash with its salt inside, at `cost` 10 unless you set 4 to 10; a bcrypt
  source longer than 72 bytes is refused at install, since bcrypt ignores the rest.
  Each hash is computed once per install, so a hash and its salt match. Its `from`
  must be a secret of the manifest, neither optional nor derived.
* **Seed-only values.** `"seedOnly": true` on a secret or var means it exists for the
  seed alone: the install form asks for it once and says so, and it is never set on
  the Worker, stored in the app's settings, or asked for again by updates or
  settings. Use it for the admin's password, so the plaintext never sits in the
  app's environment. A generated seed-only password is shown once more, with a copy
  button, on the install's job page. A seed-only value must be used by a seed, cannot
  be optional, derived or limited to some Workers, and no derived value may come
  from it. A var a seed uses must not be optional unless it has a default. Name
  the user in `postInstall`, never the password.
* **When it runs.** Only the install job seeds, after the binding's migrations,
  schema files and post-deploy migrations. With `"beforeSchema": true` it runs
  before the schema files instead, so the seeded row wins over a default row a
  schema file adds with `INSERT OR IGNORE` (claim the default admin's user name, and
  the upstream default password never lands). Updates never run a seed, even a
  changed one, and never ask for seed-only values. A rollback restores the Worker
  only.
* **What it cannot protect.** If an upstream schema file inserts a default admin and
  the seeded row is later deleted, the next update's schema file adds the default
  again. Say so in the app's `README.md`, and prefer an upstream change that drops
  the default row.

Seeds are not allowed on self-deploying entries.

## Apps of several Workers

Some apps are more than one Worker, such as a web front end and an API, or an app
that serves uploaded files from an origin of their own. List every Worker in
`install.workers`, and the manager installs, updates, and uninstalls them together
as one app:

```jsonc
"install": {
  "packageManager": "pnpm",
  "wranglerConfig": "apps/web/wrangler.jsonc",
  "workerName": "notes",
  "buildCommand": "pnpm run build",
  "workers": [
    { "name": "web", "wranglerConfig": "apps/web/wrangler.jsonc", "primary": true },
    { "name": "api", "wranglerConfig": "apps/api/wrangler.jsonc" }
  ]
},
"secrets": [
  { "name": "SESSION_SECRET", "label": "Session secret", "generate": "password", "workers": ["web"] },
  { "name": "API_KEY", "label": "API key", "generate": "password" }
],
"vars": [{ "name": "API_URL", "label": "API URL", "default": "{{appUrl:api}}" }]
```

* **Two to 24 Workers**, on the artifact tier only. Each has a `name` within the
  entry: up to 24 lowercase letters, digits, and hyphens, not starting or ending
  with a hyphen. Each wrangler config must have a `name` of its own.

* **The Worker count does not decide the plan.** Set `"plan"` for what the app
  needs, as for an app of one Worker. The manager installs and updates all of an
  app's Workers in one job, and each Worker adds requests to it. Workers Free lets a
  job make 50 at a time, so there a job for more Workers pauses for 5 minutes
  whenever it needs a fresh allowance and then carries on; the install takes longer,
  nothing else changes. On Workers Paid a job may make 10,000 requests at a time.
  Either way a job may run a limited number of steps (1,024 on Workers Free, 10,000
  on Workers Paid), and each Worker's upload runs in a request of its own, so 24
  Workers fit; the manager totals the job's steps before it starts and refuses a job
  that would not fit. Older versions of Appflare installed at most three Workers per
  app on Workers Free: the catalog's index marks a `"free"` entry of four or more so
  that they leave it out of their catalog and ask to be updated, instead of offering
  an app they cannot install.

* **Every Worker counts toward the account's limit**: 100 Workers on Workers Free,
  500 on Workers Paid. The install counts the account's Workers first and stops
  before creating anything when the app's Workers would not fit.

* **Exactly one is `primary`.** It is the app: it runs under the install's Worker
  name and serves the app's address, its custom domains, and the health check
  (`install.health.path` is a path on it). Its `wranglerConfig` must equal
  `install.wranglerConfig`, so tools that build one Worker build the primary. Every
  other Worker runs as `<install Worker name>-<name>` on its own workers.dev
  address: `notes-api` in the example.

* **Workers only the app calls.** Set `"workersDev": false` on a Worker that only the
  entry's other Workers reach, through a service binding or a Durable Object binding,
  and that must not answer from the internet: for example one that trusts identity
  headers the primary Worker sets. The manager keeps its workers.dev URL and its
  version previews off on every install, update, rollback and settings change, skips
  its preview check during updates, and the app's page lists it as not reachable
  from the internet. Leave it out for every Worker that people or other services
  call, such as a file origin, an inbox, or a webhook endpoint. The primary Worker
  cannot set it: it is the app's address and health check until the admin adds a
  custom domain, which turns its workers.dev URL off anyway. `{{appUrl:<name>}}`,
  `{{workerUrl:<name>}}` and their hostname forms are refused for such a Worker,
  since it has no address.

* **Builds.** `install.buildCommand` runs once, first. Then each Worker's own
  `buildCommand`, if it has one, runs in the order of the list. Both follow the
  rules for `install.buildCommand` above.

* **Secrets and vars.** A secret's or var's `workers` lists the Workers that get it.
  Without it, a secret goes to every Worker, and a var goes to the Workers whose
  wrangler config declares it, or to every Worker when none does.

* **One secret name, a value per Worker.** When several Workers read a secret of the
  same name that must differ between them (each its own OAuth client, say), declare
  one secret per Worker with that `name`, a `key` of its own, and `workers`, and list
  `"secret-keys"` in `requires`:

  ```jsonc
  "requires": ["secret-keys"],
  "secrets": [
    { "name": "CLIENT_ID", "key": "GITHUB_CLIENT_ID", "label": "GitHub client ID", "workers": ["github"] },
    { "name": "CLIENT_ID", "key": "GOOGLE_CLIENT_ID", "label": "Google client ID", "workers": ["google"] }
  ]
  ```

  The key is what Appflare knows the secret by: one field each in the install form
  and the app's settings, one record each, and the name `derive.from`, seed params
  and a sink's `tokenSecret` use. Each Worker still reads `CLIENT_ID`. Keys are
  letters, digits and underscores, unique among the entry's secrets; a secret
  without one is known by its name. Secrets of one name must each list `workers`,
  and no Worker may get two of them. A new version may give a released secret a key
  (or a new one): when every Worker that gets it already has a value under that
  name, updates keep the value under the new key; otherwise they ask for it.

* **The Worker that receives email.** An app that receives email
  (`install.emailRouting`) has its primary Worker receive it unless `worker` names
  another of its Workers, by its `name` within the entry:

  ```jsonc
  "requires": ["email-worker"],
  "install": {
    // ...
    "emailRouting": { "rules": ["inbox"], "catchAll": true, "worker": "api" }
  }
  ```

  That Worker exports the `email` handler, and the routing rules and catch-all
  Appflare sets up deliver to it as installed (`notes-api` in the example). Email
  Routing hands mail to a Worker by name, not over HTTP, so a Worker with
  `"workersDev": false` can receive mail. A new version may name another Worker:
  the update points the app's rules and catch-all at it, and a rollback points them
  back. List `"email-worker"` in `requires`: an older Appflare would send the mail to
  the primary Worker.

* **A var a Worker needs for itself.** Set it in that Worker's `configPatch` (see
  `install.configPatch` above), with placeholders such as `{{appUrl}}`.

* **Placeholders.** `{{appUrl:<name>}}`, `{{appHostname:<name>}}`,
  `{{workerUrl:<name>}}`, `{{workerHostname:<name>}}` and `{{workerName:<name>}}`
  give one Worker's address, its workers.dev address and its installed Worker name
  (the address forms only for a Worker on workers.dev). Without a name,
  `{{appUrl}}`, `{{workerUrl}}`, `{{workerName}}` and the rest still mean the app,
  that is, the primary Worker. An entry of one Worker cannot use the forms with a
  name.

* **Resources are shared by binding name.** Workers that both bind `DB` use one D1
  database, so they must bring the same migrations, or only one of them brings any.
  A queue one Worker sends to and another consumes is one queue. Bindings of one
  name must be of one type and declared alike.

* **Bindings between the Workers.** A service binding whose `service` is another
  Worker's wrangler `name`, and a Durable Object or Workflow binding whose
  `script_name` is, are pointed at that Worker as installed. Durable Objects and
  Workflows may live in any of the Workers. Each Worker is deployed after the Workers
  it binds to, with the primary as late as possible.

* **Workflows run from another Worker.** A Workflow belongs to the Worker whose
  wrangler config binds it without a `script_name` (or with its own `name`): the
  manager creates it with that Worker, names it `<install Worker name>-<workflow
  name>` like any other, and deletes it on uninstall. Another Worker of the entry
  runs it with a binding of the same `name` and `class_name` and a `script_name`
  naming the Worker that defines it, as wrangler's cross-script Workflow bindings
  do; the binding is pointed at that Worker as installed:

  ```jsonc
  // wrangler.audit.jsonc (the Worker "audit")
  "workflows": [{ "name": "site-audit", "binding": "SITE_AUDIT", "class_name": "SiteAudit" }]
  // wrangler.jsonc (the primary Worker)
  "workflows": [
    { "name": "site-audit", "binding": "SITE_AUDIT", "class_name": "SiteAudit", "script_name": "open-seo-audit" }
  ]
  ```

* **Workers built together by the Cloudflare Vite plugin.** A Vite app whose
  `vite.config.ts` builds other Workers (the plugin's `auxiliaryWorkers`) generates
  one config per Worker, `dist/<name>/wrangler.json`, and leaves one redirect,
  `.wrangler/deploy/config.json`, which points at the entry Worker's config and lists
  the others. Name each Worker's own config, `wrangler.jsonc` and, say,
  `wrangler.audit.jsonc`, just as for one Worker: the packer reads which config each
  generated one came from and packs the right one for each Worker. One
  `install.buildCommand` builds them all.

The pack fails on a service binding to any Worker outside the entry (the manager
refuses to install a Durable Object or Workflow binding to one), on Workers that bind
each other in a cycle, on a Workflow binding to another Worker of the entry that defines no Workflow
of that name and class, on two Workers that define one Workflow, and on one D1
binding with different migrations in two Workers.

## 2. Open a pull request

Commit messages follow Conventional Commits, for example `feat(apps): add cut`.

The pull request runs these checks:

1. The manifest is validated against the schema, which refuses a field it does not
   know, and `CODEOWNERS` must match the manifests.
2. The app is packed from the pinned commit exactly as a release would be, and every
   file's hashes are verified. Packing fails if a Worker is too large for the
   manager to upload in one request.
3. **The install check.** The packed app is deployed to a dedicated Cloudflare test
   account with random secrets and default settings. CI then requests its health
   path for up to 60 seconds, and deletes everything again. A server error, or no
   answer at all, fails the check. A plain 404 at the end passes.

Pull requests from forks get no CI secrets. The checks build the packer from the
appflare/appflare repository, which is private until launch and needs a secret to
read, so a pull request from a fork fails the `verify` check until that repository
is public.

## CODEOWNERS

`CODEOWNERS` is generated from each manifest's `maintainers`: one line
`/apps/<slug>/ @user …` per app. Maintainers review changes to their app,
including [version bumps](/catalog/bumps/). Regenerate it with `pnpm gen-codeowners`
in the catalog checkout and commit the result; CI fails if it is out of date.

## After merge

Merging to `main` publishes the app: CI packs it again, signs it, creates the
release `<slug>@<version>`, and adds it to `index.json`. Managers pick it up at their
next catalog refresh, within 30 minutes.

To change a published app's manifest, re-pin `source` in the same pull request.
A change to a released version without a new pin fails to publish, with two
exceptions:

* a change to `authors`, `tagline` or `licenseNote` alone, which `index.json` reads
  from the manifest;
* a change to the form or copy only (`name`, `summary`, `homepage`, `license`,
  `categories`, `maintainers`, `secrets`, `vars` (their `link` included),
  `postInstall`, `bump`, `openPath`), or to how
  the app goes with Cloudflare Access (the `access` block, and adding `"access"` to
  `requires`), together with `revision` raised by one (it starts at 1 when
  omitted). CI signs and publishes the revised manifest without building anything;
  managers switch to the new form without an update. For an app that is already
  protected, a manager checks for a revision every 30 minutes (seven protected apps a
  run): a path a revised `bypass` drops stops being public in the same run, but a
  path it adds stays behind the sign-in until an admin of that manager makes it
  public, and `"mode": "required"` on an app installed unprotected asks its admin to
  turn protection on rather than doing it. Anything else, such as
  `install`, `plan`, `tokenPermissions` or another change to `requires`, changes
  what gets built or provisioned and needs a new pin.
  Once a revision is published, any further change to the manifest, `authors`,
  `tagline` and `licenseNote` included, needs the next revision.
