# Downloads, and the marketplace built on it

*6 August 2026*

Two things were asked for together: the Downloads app Convoro is missing, and
somewhere to list modules for people to buy and install.

They are one piece of work, and getting that straight first is most of the
design.

## The marketplace is a Downloads site

Convoro's marketplace is not a separate product. It is a Convoro install
running the Downloads module, whose files happen to be `.zip` packages, plus a
machine-readable view of the same catalogue so an install can ask what it should
have.

That is not a shortcut. It means:

- every improvement to Downloads improves the marketplace, and the reverse
- the marketplace is exercised by its own author every day, which is the only
  reliable way a feature stays honest
- **anybody else can run one.** A community that wants to sell its own add-ons
  gets the same machinery, not a hosted service they must be granted access to

It is also how Ernest already runs a storefront on Invision — Downloads *is*
the shop there — so the shape is proven by use rather than by argument.

## Versions are the spine

The most common way a downloads feature is built wrong is treating a version as
a property of a file. It is the other way round: **a download is a name and a
description, and versions are the things that actually exist.**

```
download            what it is        title, description, category, author
download_version    what you get      version, changelog, the stored file, size
```

Everything falls out of that:

- "Download" means the newest version, and older ones stay reachable — 🚨 which
  is Ernest's standing rule that a customer must always be able to roll back
- a changelog is a version's, not a file's, so the history reads as a history
- an install asking "is there something newer than 1.2.0" is a comparison
  against a list that exists rather than a guess
- uploading an update does not overwrite anything

A download with no versions is not a download, so the first version is created
with the file rather than added to it afterwards.

## The stored file is not an attachment

Attachments belong to a post, are served through a token route, and are deleted
when the post is. A download is none of those: it outlives everything around
it, it is fetched by people who are not signed in (when it is free), and losing
one is losing the product.

So it gets its own store, its own directory, and its own rules — 🚨 including
that the file is sniffed and stored under a generated name, exactly as covers
and avatars are, because "upload a file for other people to download" is the
single most dangerous thing a forum does.

## Who may download

One question, asked in one place:

```php
Downloads::mayFetch(?array $viewer, array $download): bool
```

A free file answers from permissions alone. A paid file asks the entitlement
table. Everything else — the button, the version list, the direct URL, the
catalogue — routes through that one call, so there is no second path with a
different answer.

**Payment is not in this piece of work**, and saying so plainly is better than
half-building it. What v1 has is the *record* of an entitlement and the seam
that reads it. An operator can grant one by hand or point at an external
checkout, which is how a small storefront actually starts. A payment provider
is its own note, and it will add a way to *create* entitlements rather than
changing how they are read.

## The catalogue

A public, machine-readable view of the same files:

```
GET /downloads/catalogue.json
```

Listing, for each module: its key, its newest version, what core version it
needs, what it requires, a checksum, and whether it is free. An install reads
this to know that something newer exists.

Two rules about it:

**It lists what exists, not what you may have.** The catalogue is public and
identical for everybody — a site checking for updates should not have to
identify itself to find out that a new version is out. Whether it can *fetch*
that version is the entitlement question, asked at fetch time.

**The download URL is not in it.** A paid package's URL is issued per request,
to a site that proved who it is. Putting it in a public catalogue would make
the catalogue the leak.

## A licence key is a site, not a person

An install that wants a paid package sends a key. The key identifies **the
site**, not the account that bought it: a person may run three communities, and
a key that follows the person cannot tell them apart or be revoked for one of
them.

That also gives the phone-home an honest purpose — an install asking for an
update is telling you it exists and which version it runs, which is the same
information a "check for updates" button needs anyway.

## What this does not do

**No hosted marketplace service.** Everything runs on a normal Convoro install.
There is nothing anybody has to be given access to.

**No automatic installation from the catalogue in this piece.** Fetch and
verify is here; pressing "update" in the admin and having it apply is task #50,
which is the same machinery from the other end and deserves its own note.

**No reviews or ratings yet.** They are a real part of a downloads section and
they are a moderation surface, an abuse surface and a sorting question all at
once. Comments first, ratings when there is something to rate.

**Nothing is taken from Invision.** 🚨 The licence is revoked and their source
is off limits. What is being matched is the *shape a downloads section has* —
which is common to every forum that has ever had one — informed by using theirs
as a customer, not by reading it.


## Addendum, later the same day: putting a file up

The submit flow is built. Two entry points rather than one — submitting is
describing something that did not exist, adding a version is saying the same
thing changed, and a single form that tries to be both asks the wrong half of
the questions every time.

**A module key is an identity, and two downloads cannot hold one.** This was not
in the original note and it should have been. Uploading a second package that
called itself `topic_stars` produced a catalogue with two entries under that
key — different versions, different checksums, no way for an install to tell
which was meant. It is refused at submit, pointing at the download that already
holds the key; and the catalogue answers once regardless, because rows written
by hand (the seed is one) can still collide.

The **older** claim wins, deliberately. It is the one installs have already
fetched, and letting a newcomer take the key would silently redirect every
existing site to somebody else's package.

**A submitted file is free.** Charging for somebody else's work is an
administrator's decision, and there is no honest way for the person uploading to
make it on the site's behalf. The entitlement seam is unchanged and still
waiting for whatever eventually calls `grant()`.
