Extension API
Everything needed to describe, package, publish and download an extension. Reads are public; writes need a credential.
The registry serves the catalog the IDE installs from. An extension is identity; each of its versions is a release, and a release owns the files it is made of. Nothing is publicly visible until both the extension and the version are approved.
Accounts, tokens and the Administrator role are covered separately in
the User API. The examples below use:
New clients should use the /api/ prefix, for example
GET /api/extensions. Existing paths such as /extensions
remain supported and return the same JSON, regardless of the Accept header.
The website catalog is separate at /add-extensions.
All examples below use the preferred API base URL.
B=https://tucan.amlang.net/api
TOKEN=… # your access token, from POST /users/login
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 lives on the version, so an old version stays fully installable — its metadata is never overwritten by a newer one.
-
Resource vs file. A resource says what a version needs and
where it comes from. Only the ones we host have bytes:
aminetandurlresources have noextension_filerow at all, and/r/{name}404s for them. The registry serves only its own files. - Approval on both. A new version can be staged while the published one keeps serving.
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. The collections are disjoint:
asking for an application under /extensions is a 404,
not a redirect, and ids are unique across both, so nothing is reachable under the
wrong one.
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"
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 an entry between collections by editing a manifest. It appears in responses,
and is ignored if you send it.
Applications are published by whoever runs the registry, not by its users — an app update is not something a third party ships. Everything below is about extensions; applications only appear here because the IDE reads them through the same five endpoints.
Every endpoint
Reads are public and need nothing.
| read | returns |
|---|---|
GET /extensions?platform=&q= |
the catalog: published extensions, latest published version of each; filter by platform, search name and description |
GET /extensions/{id} | identity plus latest release info |
GET /extensions/{id}/v?page=N | versions, 10 per page, newest first |
GET /extensions/{id}/v/{version} | release info plus a resources overview |
GET /extensions/{id}/v/{version}/{platform}/r/{name} | the file itself, if we host it |
GET /applications… | the same five reads for application updates |
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 published 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 |
| write | does |
|---|---|
POST /extensions | create one, or edit one you own |
POST /extensions/{id}/v | declare a version and the resources it is made of |
PUT /extensions/{id}/v/{version}/{platform}/r/{name} | upload one file you declared |
DELETE /extensions/{id}/v/{version} | drop a version and its files |
DELETE /extensions/{id}/v/{version}/{platform}/r/{name} | drop one uploaded file |
GET / POST /extensions/{id}/users | see who works on it; invite someone |
POST /extensions/{id}/users/{username}/accept | answer an invitation — yours alone to send |
Publishing is not yours to do, and that is what makes the rest open.
A new extension and each new version are reviewed by whoever runs the registry
before they reach the catalog. Until then your work answers 404 to
everyone else and reads normally for you, so you can build, upload and correct at
your own pace without anything half-finished being installable.
Tokens, sign-up and what each failure code means are in the User API.
extension.json — identity
{
"localName": "bebbossh",
"name": "BebboSSH",
"description": "Bebbo's SSH2 suite for AmigaOS."
}
localName is the id: permanent, and in every URL. That is the whole
shape — no version, no files, no platforms. Re-posting updates the name and
description. No type here either; it comes from the collection you post
to, so a manifest still carrying the old catalog's type works unchanged.
version.json — the release
{
"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 | folders inside the extension folder to add to the IDE path |
permissions | e.g. system.execute, internet |
resources | 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 cannot disagree with what is actually downloadable |
Resources
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 a slash. Inside the archive, structure it as you please.
| field | applies to | notes |
|---|---|---|
source | all | registry, aminet or url |
platform | all | required; part of the identity and of the URL |
name | all | required; one file name, no / or \ |
folder | all | where it unpacks, relative to the extension folder. Blank means 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 do not control |
size | — | read-only, present once bytes are uploaded |
Split along ownership rather than size. Ship your own files as a
registry resource; reference third-party archives as
aminet or url instead of mirroring them. Per-platform
builds are separate resources with distinct platform values — give them
distinct names too, so the download URLs stay self-describing.
How the folder comes together
An extension installs into one folder of its own, extensions/<id>/,
and every resource declares the folder inside it where the resource is
unpacked. Several archives can contribute to the same tree:
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 in
vendor/.
Resources sharing a destination share a namespace. Two archives
containing the same path collide and one silently overwrites the other. Nothing
detects it; you find out at runtime when a file is not the one you shipped.
Generic root entries are the usual cause — README,
install, LICENSE, Icons/,
libs/. Give each third-party resource its own folder
and the problem disappears by construction.
folder cannot point outside the extension: no leading /, no
.., and no : — on AmigaOS
amStudio:extensions/other is absolute, so a colon is an escape rather
than a separator.
Manifest references are resolved relative to the extension folder, and a resource's
folder counts: unpack your scripts under folder: "scripts"
and the manifest has to say scripts/addConnection.js. The useful check
before uploading is not "is it flat" but "does it match what I reference":
lha l dist/bebbossh-ext.lha
unzip -l dist/bebbossh-ext.zip
Packing the parent directory by mistake is the common slip — everything gains a prefix and the manifest's references stop resolving. Pack from inside the folder when you want its contents at the root:
cd src && lha a ../dist/bebbossh-ext.lha *
path: making binaries reachable
Folders inside the extension folder to add to the IDE's path.
"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: a resource's folder
plus whatever directory the archive carries inside it. Change the one and the other
has to follow. A scripts-only extension needs no entry at all — scripts are named by
the manifest, not executed off the path. The same three things are rejected here as
in folder, for the same reason: a leading /, any
.., and any :.
Publishing
Three calls, and order matters: the extension must exist before a version, and a version must declare a resource before its bytes can be uploaded. Everything here carries your access token.
# 1. identity, once. You own what you create.
curl -X POST "$B/extensions" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary @extension.json
# 2. the release, and every resource it is made of
curl -X POST "$B/extensions/bebbossh/v" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary @version.json
# 3. the bytes - one call per registry resource, named exactly as declared
curl -X PUT "$B/extensions/bebbossh/v/1.0.0/amigaos/r/bebbossh-ext.lha" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/octet-stream' \
--data-binary @dist/bebbossh-ext.lha
One upload per registry resource, addressed by platform and name — four
registry resources means four PUTs, and a PUT for an undeclared name is a
400, not a silent create. aminet and url
resources are only ever declared, never uploaded.
Always send Content-Type on uploads. Without it curl
sends application/x-www-form-urlencoded, the body is parsed as a form
and consumed, and the upload arrives empty.
Releasing a new version of something that already exists
The extension is already there, so skip step 1 — it is version, then resources:
curl -X POST "$B/extensions/am-git/v" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary @version.json
# one PUT per registry resource - three platforms means three calls
for p in amigaos morphos-ppc macos-arm; do
curl -X PUT "$B/extensions/am-git/v/1.0.3/$p/r/am-git-$p.lha" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/octet-stream' \
--data-binary @dist/am-git-$p.lha
done
# what actually landed? a resource with size: null was declared but never uploaded
curl -s "$B/extensions/am-git/v/1.0.3" -H "Authorization: Bearer $TOKEN" \
| jq '.resources[] | {platform, name, size}'
Each platform needs its own entry in the version's resources[] with a
matching name. Re-posting a version you have already published is how
you correct it: resources are replaced wholesale, but one that survives keeps its
uploaded bytes, and releaseDate does not move.
What gets rejected
| response | cause |
|---|---|
400 no resource named X declared | uploading a name the version does not list |
400 X is a url/aminet resource | uploading bytes to something we do not 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 : |
400 source must be one of … | not registry, aminet or url |
400 type may only be set by an administrator | type sent in a body — the collection decides it |
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 cannot read |
404 creating a version | the extension does not exist yet |
404 on a write | wrong collection: an application addressed under /extensions, or the reverse |
401 | no token on a write, or an expired one |
403 | a valid token, but you are not an owner of this extension |
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
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"
/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.
Filtering and search
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 — it needs the admin token, since a public
all would defeat the point of approval, and returns 403
without one.
platform is matched against the latest version's
resources — the same version the listing describes — so it cannot claim support the
current release does not have. q is case-insensitive, and 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 is not what you would 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.
If a version is withdrawn
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 by the registry's operators rather than un-approved — un-approving would describe it as pending review, which it is not.
It takes effect immediately and everywhere: the version stops being the
latestVersion and the next newest published one takes over, it disappears
from /extensions/{id}/v, /extensions/{id}/v/{version} becomes
a 404, and its downloads 404 too — including for anyone
already holding the URL. Nothing is deleted, so it can be put back.
Releasing over it is the usual answer: bump the version, post a new
version.json, upload its bytes. The previous version keeps its own
metadata and files and stays installable at its own URL, which is the point of
versioned metadata.