---
name: amlang-extension
description: Working reference for the AmLang extension registry at tucan.amlang.net — the data model, every endpoint, the full JSON shapes, how to organise and pack an extension's files, how to publish it, and how to operate the service (deploy, logs, database, migrations, secrets). Use when building, packaging, versioning, publishing or debugging an extension, or when working on the registry itself.
---

# The AmLang extension registry

A development reference: everything about the registry in one place.

> This file is served publicly at <https://tucan.amlang.net/extensions/SKILL.md>,
> so it holds no secret values — only the `kubectl` commands to read them, which
> need cluster access anyway. Do not paste the dev secret or admin token in here:
> publishing rights would then be readable by anyone who fetches this URL. It does advertise that `/dev/{secret}/` endpoints
> exist; that is fine while the secret is unguessable, but it is a reason to
> rotate the secret rather than rely on nobody knowing the mechanism.

## Setup

The dev publishing secret, for anything that can't reach the cluster:

```bash
B=https://tucan.amlang.net
DEV=dev-be0bbcd03b7562c0bc972806
```

so the upload prefix is
`https://tucan.amlang.net/dev/dev-be0bbcd03b7562c0bc972806/`. It is a **path
segment, not a header** — see the note under *The dev way*.

With cluster access, read both credentials live instead (the admin token is only
available this way):

```bash
export KUBECONFIG=/home/anders/Projects/KelsonKube/config/kube/config-dev2.yml
DEV=$(kubectl get secret amlang-api-secret -o jsonpath='{.data.registry-dev-path}'      | base64 -d)
TOKEN=$(kubectl get secret amlang-api-secret -o jsonpath='{.data.registry-admin-token}' | base64 -d)
```

Needs the VPN up (`tun0`) for kubectl. The API itself is public.

## Extensions and applications

Every entry has a `type`, and it decides only which collection lists it:

| type | listed at | is |
|---|---|---|
| `extension` | `/extensions` | something a user installs into the IDE. The default |
| `application` | `/applications` | an app update — the IDE itself, say |

Both use the same table, the same versions, the same resources, and the same
download URLs one level down. `/applications/...` mirrors `/extensions/...`
exactly, filters included:

```bash
curl "$B/applications"                    # active applications, latest of each
curl "$B/applications/amstudio/v"         # its versions
curl -O "$B/applications/amstudio/v/2.1.0/amigaos/r/amstudio.lha"
```

The collections are disjoint: asking for an application under `/extensions` is a
**404**, not a redirect. As far as that collection is concerned it isn't there.
ids are unique across both, so nothing is reachable under the wrong one.

**A client never sends `type`.** The collection decides it, on write exactly as
on read, so there is no field to get right and no way to move something between
collections by editing a manifest. `type` appears in responses, and is ignored if
you send it.

| posting to | creates |
|---|---|
| `POST /dev/{secret}/extensions` | an extension |
| `POST /extensions` | an extension |
| `POST /admin/applications` | an application — **admin only** |

There is deliberately no `/dev/` route for applications: an uploader publishes
extensions, never app updates.

Everything after creation mirrors too — `/admin/applications/{id}/v`,
`/…/v/{version}/{platform}/r/{name}`, `/…/approve` — and using the wrong
collection for an existing entry is a **404**, the same answer the read paths
give. Versions and resources are otherwise identical for both.

## The model

Four tables, each owning one thing:

| table | owns | key |
|---|---|---|
| `extension` | identity: `localName`, `name`, `description`, `type`, `approved` | `localName` unique across both types |
| `extension_version` | the release: date, notes, path, permissions, editors, menu items, `approved` | `(extension, version)` |
| `extension_resource` | a declaration: `source`, `platform`, `name`, `folder`, `format` | `(version, platform, name)` |
| `extension_file` | the bytes — only for `source: registry` | one per resource |

The separations that matter:

- **Identity vs release.** The extension is created once and holds nothing that
  changes between releases. Everything releasable is on the version, so an old
  version stays fully installable — its metadata doesn't get overwritten.
- **Resource vs file.** A resource says what a version needs and where it comes
  from. Only the ones we host have bytes; `aminet` and `url` resources have no
  `extension_file` row at all, and `/r/{name}` 404s for them. The registry only
  ever serves its own files.
- **Approval on both.** A new version can be staged while the published one keeps
  serving. Nothing is publicly visible until the extension *and* the version are
  approved.

## Every endpoint

Reads are public and need nothing. Unapproved extensions and versions are visible
to an admin only.

| | |
|---|---|
| `GET /extensions[?platform=][&q=][&all=1]` | approved extensions, latest approved version of each; filter by platform, search name/description; `all=1` (admin) includes staged |
| `GET /applications[…]` + `/{id}`, `/{id}/v`, `/{id}/v/{version}`, `/{id}/v/{version}/{platform}/r/{name}` | the same five reads for `type: application` |
| `GET /extensions/{id}` | identity + latest release info |
| `GET /extensions/{id}/v?page=N` | versions, 10 per page, newest first; approved and active only |
| `GET /extensions/{id}/v/{version}` | release info + resources overview |
| `GET /extensions/{id}/v/{version}/{platform}/r/{name}` | the file, if we host it |
| `GET /extensions/SKILL.md` | this document |

### Who may write

There is no separate path for writing. An extension lives at one address and the
**caller** decides what is allowed there:

| you are | you may |
|---|---|
| nobody | read the approved catalog |
| signed in (`Authorization: Bearer …`) | create an extension — and you own what you create |
| an owner of it | everything about that extension: versions, uploads, deletions, who else works on it |
| an administrator (`X-Admin-Token`) | all of the above anywhere, plus everything under `/admin` |

Approval is the exception that keeps this safe: anyone may publish *to* the
registry, nobody may publish *from* it. A new extension and a new version both
land unapproved and are invisible to everyone but their owner until an admin
approves them, so an open write path cannot put anything in front of a user.

| | as the owner (`Authorization: Bearer`) | dev shortcut (secret in URL) |
|---|---|---|
| extension | `POST /extensions` | `POST /dev/$DEV/extensions` |
| version | `POST /extensions/{id}/v` | `POST /dev/$DEV/extensions/{id}/v` |
| resource bytes | `PUT /extensions/{id}/v/{version}/{platform}/r/{name}` | `PUT /dev/$DEV/extensions/{id}/v/{version}/{platform}/r/{name}` |
| delete version | `DELETE /extensions/{id}/v/{version}` | — |
| delete bytes | `DELETE /extensions/{id}/v/{version}/{platform}/r/{name}` | — |
| invite, remove | `GET/POST /extensions/{id}/users`, `DELETE .../users/{username}` | — |

Answering an invitation — `POST /extensions/{id}/users/{username}/accept` or
`/decline` — is yours alone, not your owner's.

Admin-only, all under one prefix so it is one nginx rule to fence off:

| | |
|---|---|
| `POST /admin/extensions/{id}/approve[?approved=false]` | publish it, or take it back |
| `POST /admin/extensions/{id}/v/{version}/approve[?approved=false]` | publish one release |
| `POST /admin/extensions/{id}/v/{version}/active[?active=false]` | withdraw one release |
| `POST /admin/extensions/{id}/users/{username}/owner` | name an owner outright |
| `GET /admin/extensions?all=1` | the listing including unapproved work |
| `GET/POST /admin/users`, `GET/DELETE /admin/users/{username}` | accounts |
| `POST /admin/applications…` | applications, which stay admin-only throughout |
| `POST /admin/test-mail` | send one message, to prove mail works |

Accounts otherwise live at `/users` — sign-up, login, and `/users/me`. See
<https://tucan.amlang.net/user/SKILL.md>.

## `extension.json` — identity

```json
{
  "localName":   "bebbossh",
  "name":        "BebboSSH",
  "description": "Bebbo's SSH2 suite for AmigaOS."
}
```

`localName` is the id, permanent, and in every URL. That's the whole shape — no
version, no files, no platforms. Re-posting updates name and description.

No `type` here: it comes from the collection you post to, and is ignored if
present — so a manifest carrying the old catalog's `type` still works.

## `version.json` — the release

```json
{
  "version":      "1.0.0",
  "releaseNotes": "First release. Connection manager, key generation.",

  "path": ["vendor/bebbossh"],
  "permissions": ["system.execute", "internet"],

  "resources": [
    { "source": "registry", "platform": "amigaos",
      "name": "bebbossh-ext.lha", "format": "lha" },

    { "source": "aminet", "platform": "amigaos",
      "name": "bebbossh.lha", "folder": "vendor",
      "remoteIdentifier": "comm/net/bebbossh", "version": "2.4" }
  ],

  "menuItems": [
    { "name": "Add connection...", "script": "addConnection.js" },
    { "name": "Generate SSH key...", "command": "bebbosshkeygen -C \"${comment}\"",
      "form": "keygen.form.xml", "interactive": true }
  ],

  "fileEditors": [
    { "extension": ".dss", "viewTemplate": "pixler.view.xml",
      "onOpen": "openDss.js", "onSave": "saveDss.js" }
  ]
}
```

| field | notes |
|---|---|
| `version` | required, unique within the extension |
| `releaseNotes` | free text |
| `releaseDate` | **read-only** — stamped by the registry on creation, and kept if you re-post the version |
| `path` | array of folders inside the extension folder to add to the IDE path |
| `permissions` | e.g. `system.execute`, `internet` |
| `resources` | see below; replaced wholesale on re-post, but a resource that survives keeps its uploaded bytes |
| `menuItems`, `fileEditors` | stored verbatim; references resolve inside the extension folder |
| `platforms` | **read-only** — derived from the resources, so it can't disagree with what's downloadable |

### A resource

| field | applies to | notes |
|---|---|---|
| `source` | all | `registry` \| `aminet` \| `url` |
| `platform` | all | required; part of the identity and the URL |
| `name` | all | required; one file name, no `/` or `\` |
| `folder` | all | where it unpacks, relative to the extension folder. Blank = the root |
| `format` | all | Any format hint up to 16 characters, lowercased; inferred from the suffix if omitted (including `tar.*`), otherwise `raw`. Any file type is accepted, including `.deb` and `.apk` |
| `remoteIdentifier` | `aminet` | required, e.g. `comm/net/bebbossh` |
| `url` | `url` | required, absolute |
| `version` | `aminet`, `url` | the upstream version, unrelated to the extension's |
| `sha256` | all | optional integrity check; worth setting for sources you don't control |
| `size` | — | **read-only**, present once bytes are uploaded |

## The shape of it

An extension installs into one folder of its own:

```
extensions/<id>/
```

Everything it ships ends up under there, and it may have whatever internal
structure suits it. Manifest references are resolved relative to that folder, so
they can name a file at the root or deeper:

```json
{ "name": "Add connection...", "script": "addConnection.js" }
{ "name": "Ask ChatGPT...",    "script": "scripts/ask.js" }
{ "extension": ".dss", "viewTemplate": "views/pixler.view.xml", "onOpen": "codec/openDss.js" }
```

Organise it however you like — the existing extensions happen to keep everything
at the root because they are small, not because they must.

**What is flat is the resource, not its contents.** A resource is one file: an
archive or a bare executable. The registry stores files, never directory trees,
which is why a resource name may not contain `/`. Inside the archive, structure
as you please.

## What an extension is made of

| file | role | referenced by |
|---|---|---|
| `*.js` | script run by a menu item or file-editor hook | `menuItems[].script`, `fileEditors[].onOpen` / `.onSave` |
| `*.form.xml` | input form shown before a command runs | `menuItems[].form` |
| `*.view.xml` | editor view template for a file type | `fileEditors[].viewTemplate` |
| `*.json` | the extension's own data | `menuItems[].menuFrom.source` |
| `*.png` | icon | the manifest, or the view |
| binaries | native executables | reached via `path`, not by name |

The three existing extensions, all flat, as a size reference:

```
sprite-pixler/   openDss.js  saveDss.js  pixler.view.xml  saveAs.view.xml  SpritePixler16.png
chatgpt-js/      ask.js  ask.form.xml  setApiKey.js  setApiKey.form.xml  clearHistory.js
bebbossh/        addConnection.js  manageConnections.js  connections.json
                 connections.add.form.xml  connections.edit.form.xml
                 connections.manage.form.xml  keygen.form.xml
```

## Naming

Worth keeping consistent with what's there:

- scripts are camelCase verbs: `addConnection.js`, `openDss.js`, `clearHistory.js`
- a form belonging to an action shares its stem: `setApiKey.js` ↔ `setApiKey.form.xml`
- forms end `.form.xml`, view templates end `.view.xml` — the suffix says what it is
- data files say what they hold: `connections.json`

## How the folder comes together

Several archives may unpack into the same extension folder, each contributing its
own part of the tree:

```
extensions/bebbossh/
  addConnection.js              <- from your archive, packed at its root
  connections.json
  keygen.form.xml
  bebbossh/                     <- from the Aminet archive, which carries its own dir
    bebbossh
    bebbosshd
    libcryptossh.library
```

### Where each resource unpacks: `folder`

Every resource declares a `folder`, relative to the extension's own folder, and
that is where it is unpacked. Blank — the default — means the extension folder
itself.

```json
{ "source": "registry", "platform": "amigaos", "name": "bebbossh-ext.lha" }
{ "source": "aminet",   "platform": "amigaos", "name": "bebbossh.lha",
  "folder": "vendor", "remoteIdentifier": "comm/net/bebbossh" }
```

```
extensions/bebbossh/
  addConnection.js          <- your archive, folder blank -> the root
  connections.json
  vendor/                   <- the Aminet archive, folder "vendor"
    bebbossh/               <- the directory that archive carries internally
      bebbossh
      libcryptossh.library
```

Whatever structure an archive contains nests **below** its `folder`. An archive
carrying its own `bebbossh/` directory, given `folder: "vendor"`, lands in
`vendor/bebbossh/` — not `vendor/`.

`folder` cannot point outside the extension: no leading `/`, no `..`, and no `:`
(on AmigaOS `amStudio:extensions/other` is absolute, so a colon is an escape).

### Why you'd use it: overlap

Resources sharing a destination share a namespace, so **two archives containing
the same path collide and one silently overwrites the other**. Nothing detects
it; you find out at runtime when a file isn't the one you shipped.

Generic root entries are the usual cause — `README`, `install`, `LICENSE`,
`Icons/`, `docs/`, `libs/`. Two Aminet archives both shipping `libs/` merge, and
same-named files inside clobber.

Give each third-party resource its own `folder` and the problem disappears by
construction. That is better than relying on the archives happening not to
overlap, and much better than relying on unpack order, which isn't a contract.

Leave `folder` blank for your own files when you want them at the root — that is
the common case, and it is safe as long as nothing else is unpacked there.

If two resources do share a folder, compare their top-level entries first:

```bash
lha l bebbossh.lha  | awk '{print $NF}' | cut -d/ -f1 | sort -u
unzip -l other.zip  | awk '{print $NF}' | cut -d/ -f1 | sort -u
```

Nothing in the schema describes that layout — it is whatever your archives
contain. The two things that must agree with it:

- **manifest references** — a `script` of `addConnection.js` needs that file at the
  folder root; `scripts/addConnection.js` needs it one level down. A resource's
  `folder` counts: unpack your scripts under `folder: "scripts"` and the manifest
  must say `scripts/addConnection.js`.
- **`path` entries** — see below

So the useful check before uploading is not "is it flat" but "does it match what I
reference":

```bash
lha l bebbossh-ext.lha
unzip -l bebbossh-ext.zip
```

Packing the parent directory by mistake is the common slip — everything gains a
prefix, and the manifest's references no longer resolve. Pack from inside the
folder if you want its contents at the root:

```bash
cd src && lha a ../dist/bebbossh-ext.lha *
```

## `path`: making binaries reachable

Folders **inside** the extension folder to add to the IDE's path.

```json
"path": ["vendor/bebbossh"]             -> extensions/bebbossh/vendor/bebbossh
"path": ["vendor/bebbossh", "vendor/bebbossh/bin"]
"path": []                              -> nothing added (scripts-only extensions)
```

`path` is where the binaries ended up, which is the resource's `folder` plus
whatever directory the archive carries inside it. Change a resource's `folder`
and its `path` entries have to follow.

Point it at wherever the binaries actually landed. A scripts-only extension needs
no entry: scripts are named by the manifest, not executed off the path.

Rejected, because each could point outside the extension's folder: a leading `/`,
any `..`, and **any `:`** — on AmigaOS `amStudio:extensions/other` is absolute, so
a colon is an escape, not a separator.

## Splitting into resources

One resource is one file. Split along ownership, not size:

```
bebbossh-ext.lha    source: registry   folder: ""        your scripts and forms
bebbossh.lha        source: aminet     folder: "vendor"  the upstream suite
```

Ship your own files as a `registry` resource — they are yours and you version
them. Reference third-party archives as `aminet` or `url` rather than mirroring
them: the registry hosts only its own files, and `/r/{name}` 404s for anything
else on purpose.

Per-platform builds are separate resources with distinct `platform` values. Give
them distinct names too — it makes the download URLs self-describing:

```
bebbossh-ext-amigaos.lha     platform: amigaos
bebbossh-ext-morphos.lha     platform: morphos-ppc
```

## A working layout

Keep the source tree shaped the way it will be installed, and the archives out of
it:

```
my-extension/
  extension.json          <- the identity POST body
  version.json            <- the version POST body: path, permissions, resources, menuItems
  src/                    <- becomes the contents of extensions/bebbossh/
    addConnection.js
    keygen.form.xml
    connections.json
  dist/                   <- built archives, gitignored
    bebbossh-ext.lha
```

```bash
cd src && lha a ../dist/bebbossh-ext.lha * && cd ..
lha l dist/bebbossh-ext.lha        # does this match what version.json references?
```

## Publishing

Three steps either way, and **order matters**: the extension must exist before a
version, and a version must declare a resource before its bytes can be uploaded.
Two complete routes, not meant to be mixed.

### The dev way — secret in the URL, no headers

Every step has a `/dev/{secret}/` form, resources included. No token header, and
no approval step: this path publishes immediately.

```bash
# 1. identity, once
curl -X POST -H 'Content-Type: application/json' --data-binary @extension.json \
  "$B/dev/$DEV/extensions"

# 2. the release
curl -X POST -H 'Content-Type: application/json' --data-binary @version.json \
  "$B/dev/$DEV/extensions/bebbossh/v"

# 3. the bytes — one call per registry resource, named exactly as declared
curl -X PUT -H 'Content-Type: application/octet-stream' --data-binary @dist/bebbossh-ext.lha \
  "$B/dev/$DEV/extensions/bebbossh/v/1.0.0/amigaos/r/bebbossh-ext.lha"
```

**One upload per registry resource**, addressed by platform and name — four
registry resources means four PUTs. `aminet` and `url` resources are never
uploaded, only declared. Append `?approved=false` to step 1 or 2 to stage instead.

**The secret is a path segment, not a header.** `/dev/$DEV/...` and
`X-Admin-Token` are two different credentials; sending the dev secret as a header,
or the admin token as a path segment, fails. On the `/dev/` routes no header is
needed at all beyond `Content-Type`.

### Releasing a new version of something that already exists

The extension is already there, so skip step 1 — it's version, then resources:

```bash
DEV=dev-be0bbcd03b7562c0bc972806

# 1. declare the version and every resource it is made of
curl -X POST -H 'Content-Type: application/json' --data-binary @version.json \
  "$B/dev/$DEV/extensions/am-git/v"

# 2. one PUT per registry resource — three platforms means three calls
for p in amigaos morphos-ppc macos-arm; do
  curl -X PUT -H 'Content-Type: application/octet-stream' \
    --data-binary @dist/am-git-$p.lha \
    "$B/dev/$DEV/extensions/am-git/v/1.0.3/$p/r/am-git-$p.lha"
done
```

Each of those platforms needs its own entry in the version's `resources[]`, with
a matching `name` — a PUT for an undeclared name is a 400, not a silent create.

Check what actually landed:

```bash
curl -s "$B/extensions/am-git/v" | jq '.items[].version'
curl -s "$B/extensions/am-git/v/1.0.3" | jq '.resources[] | {platform, name, size}'
```

A resource with `size: null` was declared but never uploaded.

### Is the secret right?

Without printing it anywhere, POST a deliberately invalid body:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
  -d 'not-json' "$B/dev/$DEV/extensions"
```

**400** means the secret was accepted and only the body was rejected — the
credential is good. **404** means the secret is wrong, since a bad secret is
deliberately indistinguishable from a route that doesn't exist. That distinction
is the quickest way to tell "wrong credential" from "wrong request".

### As an owner — a token, and someone else approves

```bash
curl -X POST "$B/extensions" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' --data-binary @extension.json

curl -X POST "$B/extensions/bebbossh/v" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' --data-binary @version.json

curl -X PUT "$B/extensions/bebbossh/v/1.0.0/amigaos/r/bebbossh-ext.lha" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/octet-stream' \
  --data-binary @dist/bebbossh-ext.lha

# and then somebody with the admin token publishes it
curl -X POST "$B/admin/extensions/bebbossh/approve"         -H "X-Admin-Token: $TOKEN"
curl -X POST "$B/admin/extensions/bebbossh/v/1.0.0/approve" -H "X-Admin-Token: $TOKEN"
```

### Everything that gets rejected

| response | cause |
|---|---|
| `400` no resource named X declared | uploading a name the version doesn't list |
| `400` X is a url/aminet resource | uploading bytes to something we don't host |
| `400` name must name a single file | `/` or `\` in a resource name |
| `400` resource.format must be at most 16 characters | Shorten the format hint or omit it; there is no file-type allowlist |
| `400` folder/path would point outside | `..`, a leading `/`, or a `:` in `folder` or `path` |
| `400` source must be one of … | not `registry`/`aminet`/`url` |
| `400` type may only be set by an administrator | `type` sent on a `/dev/` upload |
| `400` an aminet resource needs a remoteIdentifier | missing Aminet path |
| `400` a url resource needs a url | missing URL |
| `400` empty body | no `Content-Type` on the upload — see below |
| `400` body is not valid JSON | including a date the parser can't read |
| `404` on `/dev/…` | wrong dev secret — indistinguishable from an unknown route on purpose |
| `404` creating a version | the extension doesn't exist yet |
| `404` on a write | wrong collection: an application addressed under `/extensions`, or vice versa |
| `401` | no credential at all on a write: log in, or use the `/dev/` path |
| `403` | signed in, but not an owner of this extension — or an admin operation without `X-Admin-Token` |

**Always send `Content-Type` on uploads.** Without it curl sends
`application/x-www-form-urlencoded`, Play parses the body as a form and consumes
it, and the upload arrives empty.

## Reading it back

```bash
curl "$B/extensions"                      # active extensions, latest version of each
curl "$B/extensions/bebbossh"             # identity + latest release info
curl "$B/extensions/bebbossh/v?page=1"    # versions, 10 per page, newest first
curl "$B/extensions/bebbossh/v/1.0.0"     # release info + resources overview
curl -O "$B/extensions/bebbossh/v/1.0.0/amigaos/r/bebbossh-ext.lha"
```

### Filtering and search

`GET /extensions` takes two optional parameters, combinable:

```bash
curl "$B/extensions?platform=amigaos"        # only what runs on amigaos
curl "$B/extensions?q=ssh"                   # search name and description
curl "$B/extensions?q=git&platform=amigaos"  # both
```

The listing is **approved-only**: an entry appears when the extension is approved
*and* it has an approved version. Staged work never shows up, whoever is asking.

`?all=1` includes unapproved entries and takes the newest version whatever its
state, adding `approved` and `versionApproved` to each row so you can tell them
apart. (Your own unapproved extensions are visible to you at
`GET /extensions/{id}` without it — an owner can always see what they are
building.) It needs the admin token — a public `all` would defeat the point of
approval — and returns `403` without one:

```bash
curl "$B/extensions?all=1" -H "X-Admin-Token: $TOKEN"
```

`platform` is matched against the **latest version's** resources — the same
version the listing describes — so it can't claim support the current release
doesn't actually have.

`q` is case-insensitive. Multiple words are **AND**: every term has to match
something, so `?q=ssh+client` narrows rather than widens. Results come back
ranked, best first; without `q` they stay in name order.

| what matched | score |
|---|---|
| name is exactly the term | 100 |
| name starts with it | 60 |
| name contains it | 40 |
| id is exactly the term | 30 |
| id starts with it | 20 |
| id contains it | 15 |
| description contains it | 5 |

The name always outranks the id, and the id always outranks the description, so
searching `git` puts the extension *called* git above one that merely mentions it
in its blurb. The id is searched at all because the display name often isn't what
you'd type — `?q=amlang-toolchain` finds "AmLang Toolchain", which contains no
such string.

There is no search on the version endpoints; the listing is the search surface.

`/r/{name}` serves **only** the files we host. An `aminet` or `url` resource is
described in the version's resource list — with its `remoteIdentifier` or `url` —
and 404s here; the client fetches those from where they actually live.

Nothing appears in the public listing until both the extension and the version
are approved.

## Withdrawing a version

Approval says "published"; **active** says "still in circulation". A release found
to be malicious, or broken badly enough that nobody should install it, is
withdrawn rather than un-approved — un-approving would describe it as pending
review, which it isn't.

```bash
# pull it
curl -X POST "$B/admin/extensions/am-git/v/1.0.3/active?active=false" \
  -H "X-Admin-Token: $TOKEN"

# put it back
curl -X POST "$B/admin/extensions/am-git/v/1.0.3/active" -H "X-Admin-Token: $TOKEN"
```

A version is served only when it is **both approved and active**. Withdrawing one
takes effect immediately and everywhere:

- it stops being the `latestVersion` — the next newest approved, active version
  takes over
- it disappears from `/extensions/{id}/v`
- `/extensions/{id}/v/{version}` becomes a 404
- **its downloads 404**, including for anyone already holding the URL

Admins still see and can fetch it, so you can inspect what you pulled. Nothing is
deleted: restoring is the same call without `?active=false`. `/applications` has
the same endpoint.

## Versioning

Bump the version, post a new `version.json`, upload its bytes, approve. The
previous version keeps its own metadata and files and stays installable at its own
URL — that is the point of versioned metadata.

Re-posting an existing version is for corrections: resources are replaced
wholesale, but a resource that survives keeps its bytes, and `releaseDate` doesn't
move.

## Operating the registry

Deploy (bump `BASE_TAG` in the root `Makefile` first):

```bash
make build-k8s-api && make push-k8s-api && make deploy-k8s-api
make k8s-status
make logs-k8s-api
```

The image is built from `Api/Dockerfile.k8s` on `play-framework:1.8.0-jdk21`,
pushed to the in-cluster registry at `172.22.8.181:32000`, and served on NodePort
`31900`. The public name is proxied by nginx on `172.22.8.205`.

Database:

```bash
DBPOD=$(kubectl get pod -l app=amlang-api-db -o jsonpath='{.items[0].metadata.name}')
RP=$(kubectl get secret amlang-api-secret -o jsonpath='{.data.db-root-pass}' | base64 -d)
kubectl exec $DBPOD -- mysql -uroot -p"$RP" -D amlang_registry -e "
  SELECT e.localName, v.version, v.approved, r.platform, r.name, r.source, f.size
  FROM extension e
  LEFT JOIN extension_version v  ON v.extension_id = e.id
  LEFT JOIN extension_resource r ON r.extension_version_id = v.id
  LEFT JOIN extension_file f     ON f.extension_resource_id = r.id;"
```

`make backup-k8s-api-db` dumps it to a dated file.

Schema changes go in `Api/k8s/migrations/` and are normally applied **before**
deploying the image that expects them — the running pod maps the old columns.

The exception is a migration that *fixes up rows in a column `jpa.ddl=update`
has just added*, which cannot run until the column exists: `004-signup.sql` is
one, and says so in its header. Check which way round a file runs before
applying it; they are not all the same.
`kubectl exec -i … < file.sql` does not work here; copy the file in first:

```bash
kubectl cp Api/k8s/migrations/00N-x.sql $DBPOD:/tmp/m.sql
kubectl exec $DBPOD -- bash -c "mysql -uroot -p'$RP' -D amlang_registry < /tmp/m.sql"
```

Rotating the dev secret, or switching those routes off entirely:

```bash
kubectl patch secret amlang-api-secret --type=merge \
  -p '{"stringData":{"registry-dev-path":"dev-'"$(openssl rand -hex 12)"'"}}'
kubectl rollout restart deployment/amlang-api
```

An empty `registry-dev-path` disables the `/dev/` routes — they 404 like any
unknown path. Worth doing when the development phase ends: anyone who reads the
URL can publish, and it lands in the nginx access log on every upload.

## When it goes wrong

| symptom | cause |
|---|---|
| menu item does nothing; script not found | the manifest's reference doesn't match where the file landed — commonly the parent directory got packed, adding a prefix |
| binary not found at runtime | missing or wrong `path` entry; check where the archive actually unpacked, including its `folder` |
| a file is not the one you shipped | two resources unpacked to the same folder and collided — give one its own `folder` |
| extension missing from `/extensions` | extension or version not approved, or the version has no resources |
| upload succeeds, download 404s | version not approved, or wrong platform/version in the URL |
| pod crashlooping after a schema change | migration not applied, or applied after the deploy |
